Files
EJClaw/src/streamed-output-evaluator.ts
2026-06-02 18:37:37 +08:00

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;
}