refactor: move retry and recovery paths to structured output helpers
This commit is contained in:
@@ -26,6 +26,16 @@ export function getAgentOutputText(output: {
|
|||||||
return stringifyLegacyAgentResult(output.result);
|
return stringifyLegacyAgentResult(output.result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAgentOutputPayload(output: {
|
||||||
|
output?: StructuredAgentOutput;
|
||||||
|
result?: string | object | null;
|
||||||
|
}): boolean {
|
||||||
|
if (output.output) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return output.result !== null && output.result !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function isSilentAgentOutput(output: {
|
export function isSilentAgentOutput(output: {
|
||||||
output?: StructuredAgentOutput;
|
output?: StructuredAgentOutput;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ describe('classifySuppressTokenOutput', () => {
|
|||||||
|
|
||||||
it('treats a truncated structured silent envelope as mixed', () => {
|
it('treats a truncated structured silent envelope as mixed', () => {
|
||||||
expect(
|
expect(
|
||||||
classifySuppressTokenOutput('{"ejclaw":{"visibility":"silent"', undefined),
|
classifySuppressTokenOutput(
|
||||||
|
'{"ejclaw":{"visibility":"silent"',
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
).toBe('mixed');
|
).toBe('mixed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,4 +83,42 @@ describe('runClaudeRotationLoop', () => {
|
|||||||
trigger: { reason: 'success-null-result' },
|
trigger: { reason: 'success-null-result' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses structured public output text as the next rotation message', async () => {
|
||||||
|
vi.mocked(getTokenCount).mockReturnValue(2);
|
||||||
|
vi.mocked(rotateToken)
|
||||||
|
.mockReturnValueOnce(true)
|
||||||
|
.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const outcome = await runClaudeRotationLoop(
|
||||||
|
{ reason: '429' },
|
||||||
|
async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
return {
|
||||||
|
output: {
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'public', text: 'retry with next token' },
|
||||||
|
},
|
||||||
|
sawOutput: false,
|
||||||
|
streamedTriggerReason: { reason: 'usage-exhausted' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: { status: 'success', result: 'ok' },
|
||||||
|
sawOutput: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ runId: 'structured-rotation-message' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
type: 'error',
|
||||||
|
trigger: { reason: 'usage-exhausted' },
|
||||||
|
});
|
||||||
|
expect(rotateToken).toHaveBeenNthCalledWith(2, 'retry with next token');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* to eliminate the ~255-line structural duplication.
|
* to eliminate the ~255-line structural duplication.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import {
|
import {
|
||||||
classifyRotationTrigger,
|
classifyRotationTrigger,
|
||||||
shouldRotateClaudeToken,
|
shouldRotateClaudeToken,
|
||||||
@@ -26,7 +28,7 @@ export interface TriggerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RotationAttemptResult {
|
export interface RotationAttemptResult {
|
||||||
output?: { status: string; result?: string | null; error?: string | null };
|
output?: Pick<AgentOutput, 'status' | 'result' | 'output' | 'error'>;
|
||||||
thrownError?: unknown;
|
thrownError?: unknown;
|
||||||
sawOutput: boolean;
|
sawOutput: boolean;
|
||||||
sawSuccessNullResult?: boolean;
|
sawSuccessNullResult?: boolean;
|
||||||
@@ -115,8 +117,7 @@ export async function runClaudeRotationLoop(
|
|||||||
reason: attempt.streamedTriggerReason.reason,
|
reason: attempt.streamedTriggerReason.reason,
|
||||||
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
||||||
};
|
};
|
||||||
lastRotationMessage =
|
lastRotationMessage = getAgentOutputText(output) ?? undefined;
|
||||||
typeof output.result === 'string' ? output.result : undefined;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ describe('shouldResetSessionOnAgentFailure', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches structured public output text too', () => {
|
||||||
|
expect(
|
||||||
|
shouldResetSessionOnAgentFailure({
|
||||||
|
result: null,
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.11.content.0: Invalid `signature` in `thinking` block"}}',
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shouldRetryFreshSessionOnAgentFailure', () => {
|
describe('shouldRetryFreshSessionOnAgentFailure', () => {
|
||||||
@@ -75,4 +88,14 @@ describe('shouldRetryFreshSessionOnAgentFailure', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat structured silent output as a retryable session failure', () => {
|
||||||
|
expect(
|
||||||
|
shouldRetryFreshSessionOnAgentFailure({
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'silent' },
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
|
|
||||||
const SESSION_RESET_PATTERNS = [
|
const SESSION_RESET_PATTERNS = [
|
||||||
@@ -28,18 +29,24 @@ function toText(value: string | object | null | undefined): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldResetSessionOnAgentFailure(
|
export function shouldResetSessionOnAgentFailure(
|
||||||
output: Pick<AgentOutput, 'result' | 'error'>,
|
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const texts = [...toText(output.result), ...toText(output.error)];
|
const texts = [
|
||||||
|
...toText(getAgentOutputText(output)),
|
||||||
|
...toText(output.error),
|
||||||
|
];
|
||||||
return texts.some((text) =>
|
return texts.some((text) =>
|
||||||
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
|
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRetryFreshSessionOnAgentFailure(
|
export function shouldRetryFreshSessionOnAgentFailure(
|
||||||
output: Pick<AgentOutput, 'result' | 'error'>,
|
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const texts = [...toText(output.result), ...toText(output.error)];
|
const texts = [
|
||||||
|
...toText(getAgentOutputText(output)),
|
||||||
|
...toText(output.error),
|
||||||
|
];
|
||||||
return texts.some((text) =>
|
return texts.some((text) =>
|
||||||
SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)),
|
SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,6 +138,21 @@ describe('evaluateStreamedOutput', () => {
|
|||||||
expect(result.shouldForwardOutput).toBe(true);
|
expect(result.shouldForwardOutput).toBe(true);
|
||||||
expect(result.state.sawOutput).toBe(false);
|
expect(result.state.sawOutput).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats structured silent final output as output, not success-null-result', () => {
|
||||||
|
const result = evaluateStreamedOutput(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
output: { visibility: 'silent' },
|
||||||
|
},
|
||||||
|
freshState(),
|
||||||
|
{ ...claudeOpts, trackSuccessNullResult: true },
|
||||||
|
);
|
||||||
|
expect(result.shouldForwardOutput).toBe(true);
|
||||||
|
expect(result.state.sawOutput).toBe(true);
|
||||||
|
expect(result.state.sawSuccessNullResultWithoutOutput).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Claude usage-exhausted banner', () => {
|
describe('Claude usage-exhausted banner', () => {
|
||||||
@@ -180,6 +195,28 @@ describe('evaluateStreamedOutput', () => {
|
|||||||
expect(result.shouldForwardOutput).toBe(true);
|
expect(result.shouldForwardOutput).toBe(true);
|
||||||
expect(result.newTrigger).toBeUndefined();
|
expect(result.newTrigger).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects a structured public usage-exhausted banner too', () => {
|
||||||
|
vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = evaluateStreamedOutput(
|
||||||
|
{
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: "You're out of extra usage. Resets at 5pm.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
freshState(),
|
||||||
|
claudeOpts,
|
||||||
|
);
|
||||||
|
expect(result.shouldForwardOutput).toBe(false);
|
||||||
|
expect(result.newTrigger).toEqual({ reason: 'usage-exhausted' });
|
||||||
|
expect(result.state.streamedTriggerReason).toEqual({
|
||||||
|
reason: 'usage-exhausted',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Claude retryable session failure suppression', () => {
|
describe('Claude retryable session failure suppression', () => {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
getAgentOutputText,
|
||||||
|
hasAgentOutputPayload,
|
||||||
|
isSilentAgentOutput,
|
||||||
|
} from './agent-output.js';
|
||||||
import {
|
import {
|
||||||
classifyClaudeAuthError,
|
classifyClaudeAuthError,
|
||||||
classifyRotationTrigger,
|
classifyRotationTrigger,
|
||||||
@@ -53,6 +58,8 @@ export function evaluateStreamedOutput(
|
|||||||
options.agentType === 'codex' && options.provider === 'codex';
|
options.agentType === 'codex' && options.provider === 'codex';
|
||||||
const countsAsFinalOutput =
|
const countsAsFinalOutput =
|
||||||
output.phase === undefined || output.phase === 'final';
|
output.phase === undefined || output.phase === 'final';
|
||||||
|
const outputText = getAgentOutputText(output);
|
||||||
|
const silentOutput = isSilentAgentOutput(output);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isPrimaryClaude &&
|
isPrimaryClaude &&
|
||||||
@@ -71,19 +78,19 @@ export function evaluateStreamedOutput(
|
|||||||
isPrimaryClaude &&
|
isPrimaryClaude &&
|
||||||
output.status === 'success' &&
|
output.status === 'success' &&
|
||||||
!state.sawOutput &&
|
!state.sawOutput &&
|
||||||
typeof output.result === 'string'
|
typeof outputText === 'string'
|
||||||
) {
|
) {
|
||||||
const authClassification = classifyClaudeAuthError(output.result);
|
const authClassification = classifyClaudeAuthError(outputText);
|
||||||
const triggerReason: AgentTriggerReason | undefined =
|
const triggerReason: AgentTriggerReason | undefined =
|
||||||
isClaudeUsageExhaustedMessage(output.result)
|
isClaudeUsageExhaustedMessage(outputText)
|
||||||
? 'usage-exhausted'
|
? 'usage-exhausted'
|
||||||
: isClaudeOrgAccessDeniedMessage(output.result) ||
|
: isClaudeOrgAccessDeniedMessage(outputText) ||
|
||||||
authClassification.category === 'org-access-denied'
|
authClassification.category === 'org-access-denied'
|
||||||
? 'org-access-denied'
|
? 'org-access-denied'
|
||||||
: isClaudeAuthExpiredMessage(output.result) ||
|
: isClaudeAuthExpiredMessage(outputText) ||
|
||||||
authClassification.category === 'auth-expired'
|
authClassification.category === 'auth-expired'
|
||||||
? 'auth-expired'
|
? 'auth-expired'
|
||||||
: detectClaudeProviderFailureMessage(output.result) || undefined;
|
: detectClaudeProviderFailureMessage(outputText) || undefined;
|
||||||
|
|
||||||
if (triggerReason) {
|
if (triggerReason) {
|
||||||
const newTrigger = nextState.streamedTriggerReason
|
const newTrigger = nextState.streamedTriggerReason
|
||||||
@@ -100,7 +107,7 @@ export function evaluateStreamedOutput(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
options.suppressClaudeAuthErrorOutput &&
|
options.suppressClaudeAuthErrorOutput &&
|
||||||
isClaudeAuthError(output.result)
|
isClaudeAuthError(outputText)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
state: nextState,
|
state: nextState,
|
||||||
@@ -112,15 +119,15 @@ export function evaluateStreamedOutput(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
countsAsFinalOutput &&
|
countsAsFinalOutput &&
|
||||||
output.result !== null &&
|
hasAgentOutputPayload(output)
|
||||||
output.result !== undefined
|
|
||||||
) {
|
) {
|
||||||
nextState.sawOutput = true;
|
nextState.sawOutput = true;
|
||||||
} else if (
|
} else if (
|
||||||
options.trackSuccessNullResult &&
|
options.trackSuccessNullResult &&
|
||||||
isPrimaryClaude &&
|
isPrimaryClaude &&
|
||||||
output.status === 'success' &&
|
output.status === 'success' &&
|
||||||
!state.sawOutput
|
!state.sawOutput &&
|
||||||
|
!silentOutput
|
||||||
) {
|
) {
|
||||||
nextState.sawSuccessNullResultWithoutOutput = true;
|
nextState.sawSuccessNullResultWithoutOutput = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user