diff --git a/packages/cli/src/agent/loop/executeLoopGenerator.ts b/packages/cli/src/agent/loop/executeLoopGenerator.ts index 9e6ab768..c1b68157 100644 --- a/packages/cli/src/agent/loop/executeLoopGenerator.ts +++ b/packages/cli/src/agent/loop/executeLoopGenerator.ts @@ -80,7 +80,10 @@ import { import { isAbortError } from '../../utils/abort.js'; import { getAbortReason } from '../../utils/abortReason.js'; import { getCwd } from '../../utils/cwd.js'; -import { isReadOnlyAuditSubagent } from '../../utils/shell/readOnlyAudit.js'; +import { + isReadOnlyAuditSubagent, + isVerificationAuditSubagent, +} from '../../utils/shell/readOnlyAudit.js'; import type { ProjectRuleReference } from '../resources/WorkspaceProjectRules.js'; import type { SteeringMessage } from '../runtime/ActiveTurnMailbox.js'; import type { @@ -167,6 +170,9 @@ import type { const logger = createLogger(LogCategory.AGENT); +// Reserve one turn for the verdict tool and one for completion gates. +const VERIFICATION_VERDICT_RESERVE_TURNS = 2; + function toTokenUsageInfo( usage: UsageInfo, maxContextTokens: number, @@ -1258,6 +1264,9 @@ validates the object and may return a bounded corrective error.`; // === Agentic Loop === const isSubagent = !!context.subagentInfo; const readOnlyAudit = isReadOnlyAuditSubagent(context.subagentInfo?.subagentType); + const verificationAudit = isVerificationAuditSubagent( + context.subagentInfo?.subagentType + ); const builtinVerificationEnabled = options?.builtinVerification !== false; const configuredMaxTurns = deps.runtimeOptions.maxTurns ?? options?.maxTurns ?? deps.config.maxTurns ?? -1; @@ -1319,6 +1328,7 @@ validates the object and may return a bounded corrective error.`; : undefined; let structuredOutput: JsonObject | undefined = restoredStructuredOutput?.output; let structuredOutputAlreadyCompleted = restoredStructuredOutput?.completed === true; + let structuredOutputAcceptedTurn: number | undefined; let structuredOutputTruncated = false; const buildStructuredOutputFinal = () => { if (!structuredOutput || !structuredOutputContract) return undefined; @@ -1688,6 +1698,7 @@ validates the object and may return a bounded corrective error.`; }; } structuredOutput = validation.output; + structuredOutputAcceptedTurn = turnsCount; structuredOutputAlreadyCompleted = false; return { success: true, @@ -1915,6 +1926,89 @@ validates the object and may return a bounded corrective error.`; if (reachedTurnLimit) { logger.info(`Warning: 达到轮次上限 ${maxTurns} 轮`); + // A verification subagent may submit its schema-valid verdict in the + // last permitted turn. The tool result is already authoritative, so + // finish from that result instead of discarding it at the boundary. + if ( + verificationAudit && + structuredOutputContract && + structuredOutput && + structuredOutputAcceptedTurn === turnsCount + ) { + const completionSteering = await turnSteering?.drainOrSeal(); + if (options?.signal?.aborted) { + return makeInterruptedResult( + turnsCount, + allToolResults.length, + options.signal + ); + } + if (completionSteering && completionSteering.messages.length > 0) { + await invalidateGoalVerification( + 'Goal completion evidence invalidated by new user steering' + ); + structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; + structuredOutputAlreadyCompleted = false; + structuredOutputRetryCount = 0; + yield { + kind: 'steering_applied', + ...(await applySteeringMessages(completionSteering.messages)), + delivery: 'current_turn', + queue: await turnSteering!.getSnapshot(), + }; + if (options?.signal?.aborted) { + return makeInterruptedResult( + turnsCount, + allToolResults.length, + options.signal + ); + } + } else { + const structuredFinal = buildStructuredOutputFinal(); + if (structuredFinal) { + const turnFinalization = await buildTurnFinalization(); + if (options?.signal?.aborted) { + return makeInterruptedResult( + turnsCount, + allToolResults.length, + options.signal + ); + } + const persistenceMetadata: MessagePersistenceMetadata = { + ...structuredFinal.persistenceMetadata, + ...(turnFinalization ? { turnFinalization } : {}), + }; + state.appendAssistant({ + role: 'assistant', + content: structuredFinal.finalMessage, + metadata: toJsonValue(persistenceMetadata), + }); + const uuid = await saveAssistantMessage( + deps, + context, + structuredFinal.finalMessage, + lastMessageUuid, + undefined, + persistenceMetadata + ); + if (uuid) lastMessageUuid = uuid; + yield structuredFinal.event; + return { + success: true, + finalMessage: structuredFinal.finalMessage, + metadata: { + turnsCount, + toolCallsCount: allToolResults.length, + duration: Date.now() - startTime, + tokensUsed: totalTokens, + ...structuredFinal.resultMetadata, + }, + }; + } + } + } + if (options?.onTurnLimitReached) { const response = await options.onTurnLimitReached({ turnsCount }); if (options.signal?.aborted) { @@ -2111,6 +2205,7 @@ validates the object and may return a bounded corrective error.`; const queuedSteering = (await turnSteering?.drain()) ?? []; if (queuedSteering.length > 0) { structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; structuredOutputAlreadyCompleted = false; structuredOutputRetryCount = 0; yield { @@ -2428,10 +2523,29 @@ validates the object and may return a bounded corrective error.`; availableTurnTools.some((tool) => tool.name === textualCorrectionToolName) ? textualCorrectionToolName : undefined; - const turnRequiredToolName = requiredToolName ?? turnTextualCorrectionToolName; - const turnTools = turnRequiredToolName - ? availableTurnTools.filter((tool) => tool.name === turnRequiredToolName) - : availableTurnTools; + const forceVerificationVerdict = + verificationAudit && + structuredOutputContract !== undefined && + structuredOutput === undefined && + Number.isFinite(maxTurns) && + turnsCount >= maxTurns - VERIFICATION_VERDICT_RESERVE_TURNS && + turnsCount < maxTurns; + const verificationFinalizationTurn = + verificationAudit && + structuredOutputContract !== undefined && + structuredOutput !== undefined && + Number.isFinite(maxTurns) && + turnsCount === maxTurns - 1; + const turnRequiredToolName = forceVerificationVerdict + ? STRUCTURED_OUTPUT_TOOL_NAME + : verificationFinalizationTurn + ? undefined + : (requiredToolName ?? turnTextualCorrectionToolName); + const turnTools = verificationFinalizationTurn + ? [] + : turnRequiredToolName + ? availableTurnTools.filter((tool) => tool.name === turnRequiredToolName) + : availableTurnTools; // 2. 上下文压缩检查 // writeback 确保 context.messages 与 state.history 同步, @@ -2851,6 +2965,7 @@ validates the object and may return a bounded corrective error.`; const completionSteering = await turnSteering?.drainOrSeal(); if (completionSteering && completionSteering.messages.length > 0) { structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; structuredOutputAlreadyCompleted = false; structuredOutputRetryCount = 0; state.appendAssistant({ @@ -3079,6 +3194,7 @@ validates the object and may return a bounded corrective error.`; maxOutputRecoveryCount = 0; } structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; structuredOutputAlreadyCompleted = false; structuredOutputRetryCount = 0; state.appendAssistant({ @@ -3511,6 +3627,7 @@ validates the object and may return a bounded corrective error.`; 'Goal completion evidence invalidated by new user steering' ); structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; structuredOutputAlreadyCompleted = false; structuredOutputRetryCount = 0; state.appendAssistant({ @@ -4340,6 +4457,7 @@ validates the object and may return a bounded corrective error.`; ); if (newlyModifiedFiles.length > 0) { structuredOutput = undefined; + structuredOutputAcceptedTurn = undefined; structuredOutputAlreadyCompleted = false; structuredOutputRetryCount = 0; mutationRevision++; diff --git a/packages/cli/tests/unit/agent-runtime/agent/execute-loop-generator.test.ts b/packages/cli/tests/unit/agent-runtime/agent/execute-loop-generator.test.ts index c68d4b50..65f45414 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/execute-loop-generator.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/execute-loop-generator.test.ts @@ -7781,6 +7781,218 @@ describe('executeLoopGenerator', () => { }); }); + it('reserves the verifier verdict channel at the turn limit', async () => { + const deps = createMockDeps({ + runtimeOptions: { maxTurns: 3 } as any, + }); + const registry = deps.toolExecutor.getRegistry(); + vi.mocked(registry.getFunctionDeclarationsByMode).mockReturnValue([ + readTool.getFunctionDeclaration(), + ]); + const chat = deps.chatService.chat as ReturnType; + chat + .mockResolvedValueOnce({ + content: '', + toolCalls: [ + { + id: 'verifier-read-1', + type: 'function', + function: { + name: 'Read', + arguments: JSON.stringify({ path: 'package.json' }), + }, + }, + ], + finishReason: 'tool_calls', + }) + .mockResolvedValueOnce({ + content: '', + toolCalls: [ + { + id: 'verifier-read-2', + type: 'function', + function: { + name: 'StructuredOutput', + arguments: JSON.stringify({}), + }, + }, + ], + finishReason: 'tool_calls', + }) + .mockResolvedValueOnce({ + content: '', + toolCalls: [ + { + id: 'verifier-verdict', + type: 'function', + function: { + name: 'StructuredOutput', + arguments: JSON.stringify({ answer: 'validated' }), + }, + }, + ], + finishReason: 'tool_calls', + }); + + const { events, result } = await drainGenerator( + executeLoopGenerator( + deps, + 'Return a structured verification result.', + createMockContext({ + subagentInfo: { + parentSessionId: 'parent-session', + subagentType: 'verification', + isSidechain: false, + }, + }), + { + stream: false, + outputSchema, + }, + undefined + ) + ); + + expect(chat).toHaveBeenCalledTimes(3); + expect(chat.mock.calls[1]?.[3]?.toolChoice).toEqual({ + type: 'tool', + toolName: 'StructuredOutput', + }); + expect(result).toMatchObject({ + success: true, + finalMessage: '{"answer":"validated"}', + metadata: { structuredOutput: { answer: 'validated' } }, + }); + expect(events).toContainEqual({ + kind: 'structured_output', + output: { answer: 'validated' }, + schemaDigest: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + }); + + it('does not finalize a verifier verdict when steering is queued', async () => { + const { deps } = createTypedPersistenceHarness(); + deps.runtimeOptions.maxTurns = 1; + const chat = deps.chatService.chat as ReturnType; + chat.mockResolvedValueOnce({ + content: '', + toolCalls: [ + { + id: 'verifier-verdict-with-steering', + type: 'function', + function: { + name: 'StructuredOutput', + arguments: JSON.stringify({ answer: 'stale' }), + }, + }, + ], + finishReason: 'tool_calls', + }); + const turnSteering = { + drain: vi.fn(async () => []), + drainOrSeal: vi.fn(async () => ({ + messages: [ + { + id: 'new-user-steering', + content: 'Use the updated request.', + queuedAt: Date.now(), + recovered: false, + }, + ], + sealed: false, + })), + getSnapshot: vi.fn(async () => emptyFollowUpQueue()), + }; + + const { events, result } = await drainGenerator( + executeLoopGenerator( + deps, + 'Return a structured verification result.', + createMockContext({ + subagentInfo: { + parentSessionId: 'parent-session', + subagentType: 'verification', + isSidechain: false, + }, + }), + { stream: false, outputSchema, turnSteering }, + undefined + ) + ); + + expect(turnSteering.drainOrSeal).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + success: false, + error: { type: 'max_turns_exceeded' }, + }); + expect(events).toContainEqual( + expect.objectContaining({ kind: 'steering_applied' }) + ); + expect(result).not.toMatchObject({ + success: true, + metadata: { structuredOutput: { answer: 'stale' } }, + }); + }); + + it('honors cancellation before committing the final verifier verdict', async () => { + const { deps } = createTypedPersistenceHarness(); + deps.runtimeOptions.maxTurns = 1; + const controller = new AbortController(); + const chat = deps.chatService.chat as ReturnType; + chat.mockResolvedValueOnce({ + content: '', + toolCalls: [ + { + id: 'verifier-verdict-cancelled', + type: 'function', + function: { + name: 'StructuredOutput', + arguments: JSON.stringify({ answer: 'cancelled' }), + }, + }, + ], + finishReason: 'tool_calls', + }); + const turnFinalization = { + turnId: 'verifier-turn', + getInputMessageIds: vi.fn(async () => { + controller.abort(); + return []; + }), + }; + + const { result } = await drainGenerator( + executeLoopGenerator( + deps, + 'Return a structured verification result.', + createMockContext({ + subagentInfo: { + parentSessionId: 'parent-session', + subagentType: 'verification', + isSidechain: false, + }, + }), + { + stream: false, + outputSchema, + signal: controller.signal, + turnFinalization, + }, + undefined + ) + ); + + expect(result).toMatchObject({ + success: false, + error: { type: 'aborted' }, + }); + expect(turnFinalization.getInputMessageIds).toHaveBeenCalledOnce(); + expect(result).not.toMatchObject({ + success: true, + metadata: { structuredOutput: { answer: 'cancelled' } }, + }); + }); + it('commits validated structured output when blank prose exhausts the output budget', async () => { const { deps, saveMessage } = createTypedPersistenceHarness(); const chat = deps.chatService.chat as ReturnType;