227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
import { getAgentOutputText, hasAgentOutputPayload } from './agent-output.js';
|
|
import {
|
|
classifyClaudeAuthError,
|
|
classifyRotationTrigger,
|
|
detectClaudeProviderFailureMessage,
|
|
isClaudeAuthError,
|
|
isClaudeAuthExpiredMessage,
|
|
isClaudeOrgAccessDeniedMessage,
|
|
isClaudeUsageExhaustedMessage,
|
|
type AgentTriggerReason,
|
|
} from './agent-error-detection.js';
|
|
import type { AgentOutput } from './agent-runner.js';
|
|
import { detectCodexRotationTrigger } from './codex-token-rotation.js';
|
|
import {
|
|
shouldRetryFreshCodexSessionOnAgentFailure,
|
|
shouldRetryFreshSessionOnAgentFailure,
|
|
} from './session-recovery.js';
|
|
|
|
export interface StreamedTriggerReason {
|
|
reason: AgentTriggerReason;
|
|
retryAfterMs?: number;
|
|
message?: string;
|
|
}
|
|
|
|
export interface StreamedOutputState {
|
|
sawOutput: boolean;
|
|
sawVisibleOutput: boolean;
|
|
sawSuccessNullResultWithoutOutput: boolean;
|
|
streamedTriggerReason?: StreamedTriggerReason;
|
|
retryableSessionFailureDetected?: boolean;
|
|
}
|
|
|
|
export interface EvaluateStreamedOutputOptions {
|
|
agentType: 'claude-code' | 'codex';
|
|
provider: string;
|
|
suppressClaudeAuthErrorOutput?: boolean;
|
|
trackSuccessNullResult?: boolean;
|
|
shortCircuitTriggeredErrors?: boolean;
|
|
}
|
|
|
|
export interface EvaluateStreamedOutputResult {
|
|
state: StreamedOutputState;
|
|
shouldForwardOutput: boolean;
|
|
newTrigger?: StreamedTriggerReason;
|
|
suppressedAuthError?: boolean;
|
|
suppressedRetryableSessionFailure?: boolean;
|
|
}
|
|
|
|
export function evaluateStreamedOutput(
|
|
output: AgentOutput,
|
|
state: StreamedOutputState,
|
|
options: EvaluateStreamedOutputOptions,
|
|
): EvaluateStreamedOutputResult {
|
|
const nextState: StreamedOutputState = { ...state };
|
|
const primaryAgent = primaryAgentFor(options);
|
|
const countsAsFinalOutput =
|
|
output.phase === undefined || output.phase === 'final';
|
|
const outputText = getAgentOutputText(output);
|
|
|
|
const retryableSessionFailure = suppressRetryableSessionFailure(
|
|
output,
|
|
state,
|
|
nextState,
|
|
primaryAgent,
|
|
);
|
|
if (retryableSessionFailure) return retryableSessionFailure;
|
|
|
|
if (
|
|
primaryAgent === 'claude' &&
|
|
output.status === 'success' &&
|
|
!state.sawOutput &&
|
|
typeof outputText === 'string'
|
|
) {
|
|
const triggerReason = classifyClaudeSuccessTrigger(outputText);
|
|
if (triggerReason) {
|
|
const newTrigger = nextState.streamedTriggerReason
|
|
? undefined
|
|
: { reason: triggerReason };
|
|
nextState.streamedTriggerReason =
|
|
nextState.streamedTriggerReason ?? newTrigger;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: false,
|
|
newTrigger,
|
|
};
|
|
}
|
|
|
|
if (
|
|
options.suppressClaudeAuthErrorOutput &&
|
|
isClaudeAuthError(outputText)
|
|
) {
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: false,
|
|
suppressedAuthError: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (countsAsFinalOutput && hasAgentOutputPayload(output)) {
|
|
nextState.sawOutput = true;
|
|
} else if (
|
|
options.trackSuccessNullResult &&
|
|
primaryAgent === 'claude' &&
|
|
output.status === 'success' &&
|
|
!state.sawOutput
|
|
) {
|
|
nextState.sawSuccessNullResultWithoutOutput = true;
|
|
}
|
|
|
|
if (
|
|
primaryAgent === 'codex' &&
|
|
typeof output.error === 'string' &&
|
|
output.error.length > 0 &&
|
|
!nextState.sawOutput &&
|
|
!nextState.streamedTriggerReason
|
|
) {
|
|
const trigger = detectCodexRotationTrigger(output.error);
|
|
if (trigger.shouldRotate) {
|
|
const newTrigger: StreamedTriggerReason = {
|
|
reason: trigger.reason,
|
|
message: output.error,
|
|
};
|
|
nextState.streamedTriggerReason = newTrigger;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: !options.shortCircuitTriggeredErrors,
|
|
newTrigger,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (
|
|
output.status === 'error' &&
|
|
!nextState.sawOutput &&
|
|
!nextState.streamedTriggerReason
|
|
) {
|
|
let newTrigger: StreamedTriggerReason | undefined;
|
|
|
|
if (primaryAgent === 'claude') {
|
|
const trigger = classifyRotationTrigger(output.error);
|
|
if (trigger.shouldRetry) {
|
|
newTrigger = {
|
|
reason: trigger.reason,
|
|
retryAfterMs: trigger.retryAfterMs,
|
|
};
|
|
}
|
|
} else if (primaryAgent === 'codex') {
|
|
const trigger = detectCodexRotationTrigger(output.error);
|
|
if (trigger.shouldRotate) {
|
|
newTrigger = { reason: trigger.reason, message: output.error };
|
|
}
|
|
}
|
|
|
|
if (newTrigger) {
|
|
nextState.streamedTriggerReason = newTrigger;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: !options.shortCircuitTriggeredErrors,
|
|
newTrigger,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: true,
|
|
};
|
|
}
|
|
|
|
type PrimaryAgent = 'claude' | 'codex' | undefined;
|
|
|
|
function primaryAgentFor(options: EvaluateStreamedOutputOptions): PrimaryAgent {
|
|
if (options.agentType === 'claude-code' && options.provider === 'claude') {
|
|
return 'claude';
|
|
}
|
|
if (options.agentType === 'codex' && options.provider === 'codex') {
|
|
return 'codex';
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function suppressRetryableSessionFailure(
|
|
output: AgentOutput,
|
|
state: StreamedOutputState,
|
|
nextState: StreamedOutputState,
|
|
primaryAgent: PrimaryAgent,
|
|
): EvaluateStreamedOutputResult | undefined {
|
|
if (state.sawOutput) return undefined;
|
|
|
|
const shouldSuppress =
|
|
primaryAgent === 'claude'
|
|
? shouldRetryFreshSessionOnAgentFailure(output)
|
|
: primaryAgent === 'codex'
|
|
? shouldRetryFreshCodexSessionOnAgentFailure(output)
|
|
: false;
|
|
if (!shouldSuppress) return undefined;
|
|
|
|
nextState.retryableSessionFailureDetected = true;
|
|
return {
|
|
state: nextState,
|
|
shouldForwardOutput: false,
|
|
suppressedRetryableSessionFailure: true,
|
|
};
|
|
}
|
|
|
|
function classifyClaudeSuccessTrigger(
|
|
outputText: string,
|
|
): AgentTriggerReason | undefined {
|
|
if (isClaudeUsageExhaustedMessage(outputText)) return 'usage-exhausted';
|
|
|
|
const authClassification = classifyClaudeAuthError(outputText);
|
|
if (
|
|
isClaudeOrgAccessDeniedMessage(outputText) ||
|
|
authClassification.category === 'org-access-denied'
|
|
) {
|
|
return 'org-access-denied';
|
|
}
|
|
if (
|
|
isClaudeAuthExpiredMessage(outputText) ||
|
|
authClassification.category === 'auth-expired'
|
|
) {
|
|
return 'auth-expired';
|
|
}
|
|
return detectClaudeProviderFailureMessage(outputText) || undefined;
|
|
}
|