fix(agents): surface provider errors structurally instead of raw dumps (#1653)

This commit is contained in:
Will Pfleger
2026-07-08 17:32:48 -04:00
committed by GitHub
parent b2c63291f1
commit 9b244ff22b
32 changed files with 373 additions and 35 deletions
@@ -3,6 +3,8 @@ import test from "node:test";
import {
friendlyAgentLastError,
friendlyTurnErrorCopy,
MODEL_NOT_FOUND_COPY,
RELAY_MESH_DENIED_COPY,
} from "./friendlyAgentLastError.ts";
@@ -74,3 +76,75 @@ test("non-auth Agent reported error stays generic", () => {
"Agent reported error: llm: 500 internal server error",
);
});
test("code -32002 → model-not-found copy (severity: denied)", () => {
const result = friendlyAgentLastError(
"Agent reported error: llm model not found: (goose-claude-opus-4-8) 404 Not Found: ...",
-32002,
);
assert.deepEqual(result, {
severity: "denied",
copy: MODEL_NOT_FOUND_COPY,
});
});
test("code -32001 → relay mesh denied copy (structured path)", () => {
const result = friendlyAgentLastError("any error text", -32001);
assert.deepEqual(result, {
severity: "denied",
copy: RELAY_MESH_DENIED_COPY,
});
});
test("code null falls through to legacy string matching", () => {
const result = friendlyAgentLastError(
"Agent reported error: llm auth: 401 unauthorized",
null,
);
assert.deepEqual(result, {
severity: "denied",
copy: RELAY_MESH_DENIED_COPY,
});
});
test("code undefined falls through to legacy string matching", () => {
const result = friendlyAgentLastError(
"Agent reported error: llm auth: 403 forbidden",
undefined,
);
assert.deepEqual(result, {
severity: "denied",
copy: RELAY_MESH_DENIED_COPY,
});
});
test("unknown code falls through to generic", () => {
const result = friendlyAgentLastError("some error", -99999);
assert.deepEqual(result, {
severity: "generic",
copy: "some error",
});
});
test("friendlyTurnErrorCopy: numeric code -32002 → model-not-found copy", () => {
assert.equal(
friendlyTurnErrorCopy("raw error", -32002),
MODEL_NOT_FOUND_COPY,
);
});
test("friendlyTurnErrorCopy: string-encoded code coerces to number", () => {
assert.equal(
friendlyTurnErrorCopy("raw error", "-32001"),
RELAY_MESH_DENIED_COPY,
);
});
test("friendlyTurnErrorCopy: missing code falls back to raw text", () => {
assert.equal(friendlyTurnErrorCopy("raw error", undefined), "raw error");
assert.equal(friendlyTurnErrorCopy("raw error", null), "raw error");
});
test("friendlyTurnErrorCopy: unknown code passes raw text through", () => {
assert.equal(friendlyTurnErrorCopy("raw error", 12345), "raw error");
});
@@ -37,13 +37,27 @@ export type FriendlyAgentLastError =
export const RELAY_MESH_DENIED_COPY =
"Relay mesh denied this agent — check your relay membership.";
export const MODEL_NOT_FOUND_COPY =
"The configured model is not available — open agent settings and select a different one from the dropdown.";
export function friendlyAgentLastError(
raw: string | null,
code?: number | null,
): FriendlyAgentLastError | null {
if (raw == null) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
// Structured code path — no string matching, works across all providers.
if (code != null) {
switch (code) {
case -32001:
return { severity: "denied", copy: RELAY_MESH_DENIED_COPY };
case -32002:
return { severity: "denied", copy: MODEL_NOT_FOUND_COPY };
}
}
// Match either the unwrapped buzz-agent prefix or the buzz-acp wrap.
// The desktop supervisor recovers whichever appears first in the log tail.
if (
@@ -55,3 +69,13 @@ export function friendlyAgentLastError(
return { severity: "generic", copy: trimmed };
}
/**
* Convenience for `turn_error` / `agent_panic` observer payloads: coerce the
* payload's untyped `code` JSON value and return the display copy, falling
* back to the raw error text when no classification applies.
*/
export function friendlyTurnErrorCopy(raw: string, code: unknown): string {
const numeric = code == null ? null : Number(code);
return friendlyAgentLastError(raw, numeric)?.copy ?? raw;
}
@@ -88,7 +88,10 @@ export function ManagedAgentRow({
// friendly "Relay mesh denied this agent — check your relay membership."
// for auth failures so the user knows it's a membership thing, not a
// crash. Generic exits stay verbatim so we don't lie about other failures.
const friendlyError = friendlyAgentLastError(agent.lastError);
const friendlyError = friendlyAgentLastError(
agent.lastError,
agent.lastErrorCode,
);
return (
<div
@@ -304,7 +304,7 @@ function AgentPersonaCard({
? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl)
: persona.avatarUrl;
const friendlyError = agent
? friendlyAgentLastError(agent.lastError)?.copy
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
: null;
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
@@ -374,7 +374,10 @@ function StandaloneAgentCard({
}) {
const title = agent.name;
const profileQuery = useUserProfileQuery(agent.pubkey);
const friendlyError = friendlyAgentLastError(agent.lastError)?.copy;
const friendlyError = friendlyAgentLastError(
agent.lastError,
agent.lastErrorCode,
)?.copy;
const isActive = isManagedAgentActive(agent);
const opensRuntimeTab = Boolean(friendlyError && !isActive);
@@ -26,6 +26,7 @@ import {
parsePromptText,
parseSystemPromptSections,
} from "./agentSessionTranscriptHelpers";
import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError";
export { describeRawEvent } from "./agentSessionTranscriptHelpers";
@@ -771,6 +772,7 @@ export function processTranscriptEvent(
const payload = asRecord(event.payload);
const outcome = asString(payload.outcome) ?? "error";
const error = asString(payload.error) ?? "Unknown error";
const displayError = friendlyTurnErrorCopy(error, payload.code);
const title =
event.kind === "agent_panic" ? "Agent error (crash)" : "Turn error";
upsertTextItem(
@@ -778,7 +780,7 @@ export function processTranscriptEvent(
`${event.kind}:${ch}:${event.turnId ?? event.seq}`,
"lifecycle",
title,
`${outcome}: ${error}`,
`${outcome}: ${displayError}`,
event.timestamp,
ctx,
event.kind,
+2
View File
@@ -226,6 +226,7 @@ export type RawManagedAgent = {
last_stopped_at: string | null;
last_exit_code: number | null;
last_error: string | null;
last_error_code: number | null;
log_path: string;
start_on_app_launch: boolean;
auto_restart_on_config_change?: boolean;
@@ -1004,6 +1005,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
lastStoppedAt: agent.last_stopped_at,
lastExitCode: agent.last_exit_code,
lastError: agent.last_error,
lastErrorCode: agent.last_error_code ?? null,
logPath: agent.log_path,
startOnAppLaunch: agent.start_on_app_launch,
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
+1
View File
@@ -416,6 +416,7 @@ export type ManagedAgent = {
lastStoppedAt: string | null;
lastExitCode: number | null;
lastError: string | null;
lastErrorCode: number | null;
logPath: string;
startOnAppLaunch: boolean;
autoRestartOnConfigChange: boolean;
+5
View File
@@ -65,6 +65,7 @@ type MockManagedAgentSeed = {
channelIds?: string[];
backend?: RawManagedAgent["backend"];
lastError?: string | null;
lastErrorCode?: number | null;
respondTo?: RawManagedAgent["respond_to"];
respondToAllowlist?: string[];
};
@@ -460,6 +461,7 @@ type RawManagedAgent = {
last_stopped_at: string | null;
last_exit_code: number | null;
last_error: string | null;
last_error_code: number | null;
log_path: string;
start_on_app_launch: boolean;
auto_restart_on_config_change?: boolean;
@@ -1042,6 +1044,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
last_stopped_at: agent.last_stopped_at,
last_exit_code: agent.last_exit_code,
last_error: agent.last_error,
last_error_code: agent.last_error_code,
log_path: agent.log_path,
start_on_app_launch: agent.start_on_app_launch,
auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true,
@@ -1557,6 +1560,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
last_stopped_at: status === "stopped" ? now : null,
last_exit_code: null,
last_error: seed.lastError ?? null,
last_error_code: seed.lastErrorCode ?? null,
log_path: `/tmp/mock-agent-${seed.pubkey}.log`,
start_on_app_launch: true,
auto_restart_on_config_change: true,
@@ -6628,6 +6632,7 @@ async function handleCreateManagedAgent(
last_stopped_at: null,
last_exit_code: null,
last_error: null,
last_error_code: null,
log_path: `/tmp/mock-agent-${pubkey}.log`,
start_on_app_launch: args.input.startOnAppLaunch ?? true,
auto_restart_on_config_change: true,