Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/junior-evals/evals/core/lifecycle-and-resilience.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,47 @@ describeEval("Lifecycle and Resilience", slackEvals, (it) => {
expect(visibleThreadReplies(result.session)).toHaveLength(1);
});

it("when xAI returns a generic 503, explain the temporary outage without leaking provider details", async ({
run,
}) => {
const productionServiceUnavailableError =
'503 {"type":"error","error":{"type":"service_unavailable_error","message":"Service temporarily unavailable"},"providerMetadata":{"gateway":{"originalModelId":"xai/grok-4.5","resolvedProvider":"xai","fallbacksAvailable":[],"modelAttemptCount":1,"totalProviderAttemptCount":1}}}';
const result = await run({
overrides: {
reply_results: [
{
assistant_message_count: 0,
error_message: productionServiceUnavailableError,
outcome: "provider_error",
stop_reason: "error",
text: "",
},
],
},
initialEvents: [mention("Create a Linear ticket for this incident")],
criteria: rubric({
pass: [
"The reply neutrally explains that the model provider had a temporary problem and asks the user to try again.",
"The reply includes a reference event ID for support.",
],
fail: [
"Do not expose xAI, Grok, gateway metadata, raw JSON, HTTP status codes, or provider exception text.",
"Do not describe the failure as an unexplained internal error.",
],
}),
});

const replies = visibleThreadReplies(result.session);
expect(replies).toHaveLength(1);
const text = textContent(replies[0]?.content);
expect(text).toContain("temporary connection problem");
expect(text).not.toContain("capacity");
expect(text).toMatch(/event_id=[a-f0-9]+/);
expect(text).not.toContain("xai");
expect(text).not.toContain("service_unavailable_error");
expect(text).not.toContain("503");
});

it("when a short reply is interrupted by the provider, keep the partial answer in one marked post", async ({
run,
}) => {
Expand Down
10 changes: 10 additions & 0 deletions packages/junior-evals/src/behavior-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { appendAndEnqueueInboundMessage } from "@/chat/task-execution/store";
import { executeAgentRun } from "@/chat/agent";
import { completedAgentRun } from "@/chat/runtime/agent-run-outcome";
import type { AgentRunner } from "@/chat/runtime/agent-runner";
import { createProviderError } from "@/chat/services/provider-error";
import { addAgentTurnUsage, type AgentTurnUsage } from "@/chat/usage";
import { resumeAwaitingSlackContinuation } from "@/chat/runtime/agent-continue-runner";
import { scheduleAgentContinue } from "@/chat/services/agent-continue";
Expand Down Expand Up @@ -1927,6 +1928,15 @@ function buildRuntimeServices(
...(replyResult.stop_reason
? { stopReason: replyResult.stop_reason }
: {}),
...(replyResult.outcome === "provider_error" &&
replyResult.error_message
? {
providerError: createProviderError(
replyResult.error_message,
{ modelId: "eval-reply-result" },
),
}
: {}),
toolCalls: replyResult.tool_calls ?? [],
toolErrorCount: replyResult.tool_error_count ?? 0,
toolResultCount: replyResult.tool_result_count ?? 0,
Expand Down
11 changes: 3 additions & 8 deletions packages/junior/src/chat/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
* stay outside this module.
*/
import { Agent, type AgentLoopTurnUpdate } from "@earendil-works/pi-agent-core";
import { isRetryableAssistantError } from "@earendil-works/pi-ai";
import type { FileUpload } from "chat";
import { botConfig } from "@/chat/config";
import {
Expand Down Expand Up @@ -64,10 +63,8 @@ import { shouldEmitDevAgentTrace } from "@/chat/runtime/dev-agent-trace";
import { isTurnInputCommitLostError } from "@/chat/runtime/turn";
import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome";
import { buildTurnResult } from "@/chat/services/turn-result";
import {
isProviderRetryError,
nextProviderRetry,
} from "@/chat/services/provider-retry";
import { nextProviderRetry } from "@/chat/services/provider-retry";
import { isProviderRetryError } from "@/chat/services/provider-error";
import {
configuredTurnReasoningLevel,
selectTurnReasoningLevel,
Expand Down Expand Up @@ -1022,10 +1019,8 @@ async function executeAgentRunInPrivacyContext(

const providerRetry = nextProviderRetry({
attempt,
failure: lastAssistant,
messages: agent!.state.messages,
retryableFailure:
lastAssistant !== undefined &&
isRetryableAssistantError(lastAssistant),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thrown failures skip normalization

Medium Severity

The agent catch path stores the raw thrown value as providerError, while finalizeFailedTurnReply only emits actionable copy and provider attributes when that value is a ProviderError. buildTurnResult normalizes via createProviderError, so transport throws that never become assistant stopReason: "error" messages still get the generic terminal reply and empty provider telemetry.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 22698f0. Configure here.

});
if (!providerRetry) {
break;
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/chat/pi/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
import {
createProviderError,
isProviderRetryError,
} from "@/chat/services/provider-retry";
} from "@/chat/services/provider-error";
import { hasCompactedConversationContext } from "@/chat/services/context-compaction-marker";

const GATEWAY_PROVIDER = "vercel-ai-gateway" as const;
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/chat/runtime/slack-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import type { Message, MessageContext, Thread } from "chat";
import type { Destination } from "@sentry/junior-plugin-api";
import { getSubscribedReplyPreflightDecision } from "@/chat/services/subscribed-decision";
import { isProviderRetryError } from "@/chat/services/provider-retry";
import { isProviderRetryError } from "@/chat/services/provider-error";
import { AuthorizationFlowDisabledError } from "@/chat/services/auth-pause";
import { SlackActionError } from "@/chat/slack/client";
import {
Expand Down
244 changes: 244 additions & 0 deletions packages/junior/src/chat/services/provider-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
export type ProviderErrorKind =
| "auth"
| "permission"
| "rate_limit"
| "capacity"
| "timeout"
| "network"
| "server"
| "invalid_request"
| "quota"
| "content_policy"
| "unknown";

interface ProviderErrorContext {
modelId?: string;
}

interface ProviderErrorDetails extends ProviderErrorContext {
kind: ProviderErrorKind;
status?: number;
retryable: boolean;
retryAfterMs?: number;
fallbackEligible: boolean;
}

/** Structured failure produced at an AI provider boundary. */
export class ProviderError extends Error {
readonly code = "provider_error";
readonly kind: ProviderErrorKind;
readonly status?: number;
readonly modelId?: string;
readonly retryable: boolean;
readonly retryAfterMs?: number;
readonly fallbackEligible: boolean;

constructor(message: string, details: ProviderErrorDetails, cause?: unknown) {
super(`AI provider error: ${message || "Unknown provider error"}`, {
cause,
});
this.name = "ProviderError";
this.kind = details.kind;
this.status = details.status;
this.modelId = details.modelId;
this.retryable = details.retryable;
this.retryAfterMs = details.retryAfterMs;
this.fallbackEligible = details.fallbackEligible;
}
}

type ErrorShape = Error & {
status?: unknown;
statusCode?: unknown;
responseHeaders?: unknown;
response?: { headers?: unknown };
};

function providerMessage(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).trim();
}

function extractStatus(error: unknown, message: string): number | undefined {
if (error instanceof Error) {
const shaped = error as ErrorShape;
if (typeof shaped.status === "number") return shaped.status;
if (typeof shaped.statusCode === "number") return shaped.statusCode;
}
const match = message.match(
/^(?:Error:\s*)?([45]\d\d)\b|\bstatus(?:Code)?["'=:\s]+([45]\d\d)\b/i,
);
const status = match?.[1] ?? match?.[2];
return status ? Number(status) : undefined;
}

function extractField(message: string, field: string): string | undefined {
return message.match(new RegExp(`"${field}"\\s*:\\s*"([^"]+)"`, "i"))?.[1];
}

function extractRetryAfterMs(
error: unknown,
message: string,
): number | undefined {
const headers =
error instanceof Error
? ((error as ErrorShape).responseHeaders ??
(error as ErrorShape).response?.headers)
: undefined;
const retryAfter =
headers instanceof Headers
? headers.get("retry-after")
: headers && typeof headers === "object"
? Object.entries(headers as Record<string, unknown>).find(
([name]) => name.toLowerCase() === "retry-after",
)?.[1]
: undefined;
const raw =
typeof retryAfter === "string" || typeof retryAfter === "number"
? String(retryAfter)
: message.match(/"retry-after"\s*:\s*"?([^",}]+)"?/i)?.[1];
if (!raw) return undefined;
const seconds = Number(raw);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
const date = Date.parse(raw);
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
}
Comment thread
cursor[bot] marked this conversation as resolved.

function classifyProviderError(
status: number | undefined,
message: string,
): ProviderErrorKind {
if (/content.?policy|safety|moderation/i.test(message))
return "content_policy";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overbroad safety error classification

Medium Severity

classifyProviderError treats any message containing bare safety as content_policy, which is non-retryable. Transient provider failures whose text happens to include that word are therefore dropped from retry/fallback even when status or other signals indicate a temporary outage.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 22698f0. Configure here.

if (
/insufficient.?quota|quota exceeded|usage limit|monthly usage limit|available balance|out of budget|billing/i.test(
message,
)
)
return "quota";
if (
status === 401 ||
/invalid.?api.?key|no api key|authentication|(?:invalid|missing|expired|revoked).{0,24}\bcredentials?\b|\bcredentials?\b.{0,24}(?:invalid|missing|expired|revoked)|\bno\b.{0,16}\bcredentials?\b/i.test(
message,
)
)
return "auth";
if (status === 403 || /permission|forbidden|authorization/i.test(message))
return "permission";
if (status === 429 || /rate.?limit|too many requests/i.test(message))
return "rate_limit";
if (/overloaded|at capacity|capacity exceeded/i.test(message))
return "capacity";
if (/timed? out|timeout/i.test(message)) return "timeout";
if (
/network.?error|connection|fetch failed|socket|stream ended|ended before|ECONNRESET/i.test(
message,
)
)
return "network";
if (status !== undefined && status >= 500) return "server";
if (
/service.?unavailable|internal server error|bad gateway|gateway timeout|upstream (?:request )?failed|unexpected error occurred/i.test(
message,
)
)
return "server";
if (status !== undefined && status >= 400) return "invalid_request";
if (
/context.?length|context.?window|validation|bad request|unsupported model|invalid model|unknown (?:ai gateway )?model|mismatched api/i.test(
message,
)
)
return "invalid_request";
return "unknown";
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** Normalize SDK, gateway, and Pi failures into one provider error contract. */
export function createProviderError(
error: unknown,
context: ProviderErrorContext = {},
): ProviderError {
if (error instanceof ProviderError) return error;
const message = providerMessage(error);
const status = extractStatus(error, message);
const kind = classifyProviderError(status, message);
return new ProviderError(
message,
{
kind,
status,
modelId: context.modelId ?? extractField(message, "originalModelId"),
retryable:
Boolean(message) &&
["rate_limit", "capacity", "timeout", "network", "server"].includes(
kind,
),
retryAfterMs: extractRetryAfterMs(error, message),
fallbackEligible: ["capacity", "timeout", "network", "server"].includes(
kind,
),
},
error,
);
}

/** Return whether a provider-boundary error should be retried. */
export function isProviderRetryError(error: unknown): error is ProviderError {
return error instanceof ProviderError && error.retryable;
}

/** Classify a terminal Pi assistant error through the provider error contract. */
export function isRetryableProviderMessage(message: {
stopReason?: string;
errorMessage?: string;
}): boolean {
return (
message.stopReason === "error" &&
Boolean(message.errorMessage) &&
createProviderError(message.errorMessage).retryable
);
}

/** Return stable, sanitized copy suitable for a terminal user response. */
export function getProviderErrorUserMessage(error: ProviderError): string {
switch (error.kind) {
case "capacity":
return "The selected model is temporarily unavailable because it is at capacity. Please try again shortly.";
case "rate_limit": {
const seconds = error.retryAfterMs
? Math.max(1, Math.ceil(error.retryAfterMs / 1_000))
: undefined;
return seconds
? `The model is rate-limited. Please try again in about ${seconds} seconds.`
: "The model is rate-limited. Please try again shortly.";
}
case "timeout":
case "network":
case "server":
return "The model provider had a temporary connection problem. Please try again.";
case "auth":
case "permission":
return "The model provider rejected Junior's credentials. This needs an administrator or configuration fix.";
case "quota":
return "The model provider's usage quota has been exhausted. This needs an administrator or billing configuration fix.";
default:
return "";
}
}

/** Return safe structured fields for terminal failure telemetry. */
export function getProviderErrorAttributes(
error: ProviderError,
): Record<string, unknown> {
return {
"app.ai.provider_error.kind": error.kind,
"app.ai.provider_error.retryable": error.retryable,
"app.ai.provider_error.fallback_eligible": error.fallbackEligible,
...(error.status !== undefined
? { "app.ai.provider_error.status": error.status }
: {}),
...(error.retryAfterMs !== undefined
? { "app.ai.provider_error.retry_after_ms": error.retryAfterMs }
: {}),
...(error.modelId ? { "gen_ai.request.model": error.modelId } : {}),
};
Comment thread
cursor[bot] marked this conversation as resolved.
}
Loading
Loading