agent-ecosystem/src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts
2026-05-18 01:57:16 +03:00

250 lines
7.5 KiB
TypeScript

import type { MemberRuntimeAdvisory } from '@shared/types';
export interface RuntimeDiagnosticClassification {
reasonCode: NonNullable<MemberRuntimeAdvisory['reasonCode']>;
normalizedMessage: string | null;
priority: number;
actionRequired: boolean;
generic: boolean;
}
interface RuntimeDiagnosticRule {
reasonCode: RuntimeDiagnosticClassification['reasonCode'];
tokens: readonly string[];
priority: number;
actionRequired?: boolean;
generic?: boolean;
normalizeMessage?: (message: string) => string;
}
const SECRET_VALUE_PATTERNS = [
/\bsk-[A-Z0-9_-]{12,}\b/gi,
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
] as const;
const DISK_FULL_MESSAGE =
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.';
const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
{
reasonCode: 'filesystem_error',
tokens: ['enospc', 'no space left on device', 'disk is full', 'local disk is full'],
priority: 100,
actionRequired: true,
normalizeMessage: () => DISK_FULL_MESSAGE,
},
{
reasonCode: 'quota_exhausted',
tokens: [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'usage exceeded',
'free usage exceeded',
'insufficient credits',
'key limit exceeded',
'total limit',
'subscribe to go',
],
priority: 95,
actionRequired: true,
},
{
reasonCode: 'auth_error',
tokens: [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
'does not have access',
'please run /login',
],
priority: 94,
actionRequired: true,
},
{
reasonCode: 'rate_limited',
tokens: ['rate limit', 'too many requests', '429', 'model cooldown', 'cooling down'],
priority: 85,
},
{
reasonCode: 'codex_native_timeout',
tokens: ['codex native exec timed out'],
priority: 80,
},
{
reasonCode: 'backend_error',
tokens: [
'opencode_prompt_acceptance_unknown_after_bridge_timeout',
'opencode bridge outcome unknown after timeout',
],
priority: 20,
generic: true,
normalizeMessage: () => OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE,
},
{
reasonCode: 'backend_error',
tokens: ['opencode bridge command timed out'],
priority: 20,
generic: true,
},
{
reasonCode: 'network_error',
tokens: ['timeout', 'timed out', 'network', 'connection', 'econn', 'enotfound', 'fetch failed'],
priority: 70,
},
{
reasonCode: 'provider_overloaded',
tokens: ['overloaded', 'temporarily unavailable', 'service unavailable', '503'],
priority: 65,
},
{
reasonCode: 'protocol_proof_missing',
tokens: [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
'could not be verified',
'no visible reply has been found yet',
],
priority: 60,
generic: true,
},
{
reasonCode: 'backend_error',
tokens: [
'empty_assistant_turn',
'empty assistant turn',
'prompt_delivered_no_assistant_message',
'accepted the prompt, but no assistant turn was recorded',
'opencode runtime delivery did not complete',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'recreated opencode session before message delivery',
'opencode session reconcile skipped because the stored session is stale',
'opencode bootstrap mcp did not complete required tools before assistant response',
'existing app mcp config does not expose environment',
'messageabortederror',
'aborted',
'bridge stdout was empty',
],
priority: 20,
generic: true,
},
] as const;
const UNKNOWN_CLASSIFICATION: RuntimeDiagnosticClassification = {
reasonCode: 'unknown',
normalizedMessage: null,
priority: 0,
actionRequired: false,
generic: true,
};
function stripLatestAssistantFailurePrefix(message: string): string {
const marker = ' failed with ';
const lowerMessage = message.toLowerCase();
const markerIndex = lowerMessage.indexOf(marker);
if (!lowerMessage.startsWith('latest assistant message ') || markerIndex < 0) {
return message;
}
const errorNameStart = markerIndex + marker.length;
const dashIndex = message.indexOf('-', errorNameStart);
const colonIndex = message.indexOf(':', errorNameStart);
const separatorIndexes = [dashIndex, colonIndex]
.filter((index) => index >= 0)
.sort((left, right) => left - right);
if (separatorIndexes.length === 0) {
return message;
}
const separatorIndex = separatorIndexes[0];
const errorName = message.slice(errorNameStart, separatorIndex).trim();
if (!/^[A-Za-z][A-Za-z0-9_.]*Error$/.test(errorName)) {
return message;
}
return message.slice(separatorIndex + 1).trimStart();
}
export function normalizeRuntimeDiagnosticMessage(
message: string | null | undefined
): string | null {
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
(current, pattern) => current.replace(pattern, '[redacted]'),
message ?? ''
);
const normalized = stripLatestAssistantFailurePrefix(
scrubbed.replace(/\s+/g, ' ').trim()
).replace(/^APIError\s*[-:]\s*/i, '');
return normalized.length > 0 ? normalized : null;
}
export function classifyRuntimeDiagnostic(
message: string | null | undefined
): RuntimeDiagnosticClassification {
const normalizedMessage = normalizeRuntimeDiagnosticMessage(message);
if (!normalizedMessage) {
return { ...UNKNOWN_CLASSIFICATION };
}
const normalized = normalizedMessage.toLowerCase();
const rule = RUNTIME_DIAGNOSTIC_RULES.find((candidate) =>
candidate.tokens.some((token) => normalized.includes(token))
);
if (!rule) {
return {
reasonCode: 'backend_error',
normalizedMessage,
priority: 50,
actionRequired: false,
generic: false,
};
}
return {
reasonCode: rule.reasonCode,
normalizedMessage: rule.normalizeMessage?.(normalizedMessage) ?? normalizedMessage,
priority: rule.priority,
actionRequired: rule.actionRequired === true,
generic: rule.generic === true,
};
}
export function selectRuntimeDiagnosticClassification(
messages: readonly (string | null | undefined)[]
): RuntimeDiagnosticClassification | null {
let selected: RuntimeDiagnosticClassification | null = null;
for (const message of messages) {
const classified = classifyRuntimeDiagnostic(message);
if (!classified.normalizedMessage) {
continue;
}
if (!selected || classified.priority > selected.priority) {
selected = classified;
}
}
return selected;
}