mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(agents): surface provider errors structurally instead of raw dumps (#1653)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user