fix: hide raw agent failures in group chats

(cherry picked from commit 1969452c3f)
This commit is contained in:
Peter Steinberger
2026-04-26 09:19:20 +01:00
parent 308ba59151
commit 0ca3fae91a
4 changed files with 217 additions and 22 deletions

View File

@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.

View File

@@ -176,6 +176,11 @@ OpenClaw resolves that behavior by conversation type:
- Groups/channels allow silence by default.
- Internal orchestration allows silence by default.
OpenClaw also uses silent replies for internal runner failures that happen
before any assistant reply in non-direct chats, so groups/channels do not see
gateway error boilerplate. Direct chats show compact failure copy by default;
raw runner details are shown only when `/verbose` is `on` or `full`.
Defaults live under `agents.defaults.silentReply` and
`agents.defaults.silentReplyRewrite`; `surfaces.<id>.silentReply` and
`surfaces.<id>.silentReplyRewrite` can override them per surface.

View File

@@ -3,6 +3,7 @@ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-erro
import type { SessionEntry } from "../../config/sessions.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import type { TemplateContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { MAX_LIVE_SWITCH_RETRIES } from "./agent-runner-execution.js";
import type { FollowupRun } from "./queue.js";
@@ -18,6 +19,9 @@ const state = vi.hoisted(() => ({
createBlockReplyDeliveryHandlerMock: vi.fn(),
}));
const GENERIC_RUN_FAILURE_TEXT =
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params),
}));
@@ -260,14 +264,17 @@ function createMockReplyOperation(): {
function createMinimalRunAgentTurnParams(overrides?: {
followupRun?: FollowupRun;
opts?: GetReplyOptions;
sessionCtx?: TemplateContext;
}) {
return {
commandBody: "fix it",
followupRun: overrides?.followupRun ?? createFollowupRun(),
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
sessionCtx:
overrides?.sessionCtx ??
({
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext),
opts: overrides?.opts ?? ({} satisfies GetReplyOptions),
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
@@ -1706,9 +1713,9 @@ describe("runAgentTurnWithFallback", () => {
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("Agent failed before reply");
expect(result.payload.text).toContain("All models failed");
expect(result.payload.text).toContain("402 (billing)");
expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT);
expect(result.payload.text).not.toContain("All models failed");
expect(result.payload.text).not.toContain("402 (billing)");
expect(result.payload.text).not.toContain("Rate-limited");
}
});
@@ -1923,7 +1930,7 @@ describe("runAgentTurnWithFallback", () => {
expect(failMock).not.toHaveBeenCalled();
});
it("forwards sanitized generic errors on external chat channels", async () => {
it("uses compact generic copy for raw external chat errors when verbose is off", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("INVALID_ARGUMENT: some other failure"),
);
@@ -1953,6 +1960,42 @@ describe("runAgentTurnWithFallback", () => {
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT);
}
});
it("forwards sanitized generic errors on external chat channels when verbose is on", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("INVALID_ARGUMENT: some other failure"),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: {},
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "on",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(
@@ -1961,7 +2004,83 @@ describe("runAgentTurnWithFallback", () => {
}
});
it("formats raw Codex API payloads before forwarding external errors", async () => {
it.each(["group", "channel"] as const)(
"keeps raw runner failure boilerplate out of Discord %s chats",
async (chatType) => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: chatType,
GroupSubject: "agent group",
GroupChannel: "#general",
MessageSid: "msg",
} as unknown as TemplateContext,
}),
);
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(SILENT_REPLY_TOKEN);
}
},
);
it("uses compact generic copy for raw runner failures in normal Discord direct chats", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: "direct",
MessageSid: "msg",
} as unknown as TemplateContext,
}),
);
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT);
}
});
it("keeps raw runner failure guidance visible in verbose Discord direct chats", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("openai-codex/gpt-5.5 ended with an incomplete terminal response"),
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
...createMinimalRunAgentTurnParams({
sessionCtx: {
Provider: "discord",
Surface: "discord",
ChatType: "direct",
MessageSid: "msg",
} as unknown as TemplateContext,
}),
resolvedVerboseLevel: "on",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("Agent failed before reply");
expect(result.payload.text).toContain("incomplete terminal response");
}
});
it("formats raw Codex API payloads before forwarding verbose external errors", async () => {
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error(
'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}',
@@ -1990,7 +2109,7 @@ describe("runAgentTurnWithFallback", () => {
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
resolvedVerboseLevel: "on",
});
expect(result.kind).toBe("final");

View File

@@ -356,6 +356,37 @@ function collapseRepeatedFailureDetail(message: string): string {
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai", "openai-codex"]);
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
type ExternalRunFailureReply = {
text: string;
isGenericRunnerFailure: boolean;
};
function isNonDirectConversationContext(ctx: TemplateContext): boolean {
const chatType = normalizeLowercaseStringOrEmpty(ctx.ChatType);
return chatType === "group" || chatType === "channel";
}
function isVerboseFailureDetailEnabled(level: VerboseLevel | undefined): boolean {
return level === "on" || level === "full";
}
function resolveExternalRunFailureTextForConversation(params: {
text: string;
sessionCtx: TemplateContext;
isGenericRunnerFailure: boolean;
}): string {
if (!isNonDirectConversationContext(params.sessionCtx)) {
return params.text;
}
if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) {
return params.text;
}
return SILENT_REPLY_TOKEN;
}
function buildMissingApiKeyFailureText(message: string): string | null {
const normalizedMessage = collapseRepeatedFailureDetail(message);
@@ -379,7 +410,7 @@ function formatForwardedExternalRunFailureText(message: string): string {
.replace(/^\s*/u, "")
.replace(/\s+/gu, " ");
if (!sanitized) {
return "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
return GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
}
const detail =
sanitized.length > EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS
@@ -389,24 +420,41 @@ function formatForwardedExternalRunFailureText(message: string): string {
return `⚠️ Agent failed before reply: ${detail}${suffix} Please try again, or use /new to start a fresh session.`;
}
function buildExternalRunFailureText(message: string): string {
function buildExternalRunFailureReply(
message: string,
options?: { includeDetails?: boolean },
): ExternalRunFailureReply {
const normalizedMessage = collapseRepeatedFailureDetail(message);
if (isToolResultTurnMismatchError(normalizedMessage)) {
return "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.";
return {
text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
isGenericRunnerFailure: false,
};
}
const missingApiKeyFailure = buildMissingApiKeyFailureText(normalizedMessage);
if (missingApiKeyFailure) {
return missingApiKeyFailure;
return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
}
const oauthRefreshFailure = classifyOAuthRefreshFailure(normalizedMessage);
if (oauthRefreshFailure) {
const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider);
if (oauthRefreshFailure.reason) {
return `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`;
return {
text: `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`,
isGenericRunnerFailure: false,
};
}
return `⚠️ Model login failed on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Please try again. If this keeps happening, re-auth with \`${loginCommand}\`.`;
return {
text: `⚠️ Model login failed on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Please try again. If this keeps happening, re-auth with \`${loginCommand}\`.`,
isGenericRunnerFailure: false,
};
}
return formatForwardedExternalRunFailureText(normalizedMessage);
return {
text: options?.includeDetails
? formatForwardedExternalRunFailureText(normalizedMessage)
: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
isGenericRunnerFailure: true,
};
}
function shouldApplyOpenAIGptChatGuard(params: { provider?: string; model?: string }): boolean {
@@ -1460,13 +1508,19 @@ export async function runAgentTurnWithFallback(params: {
? "⚠️ Agent failed before reply: model switch could not be completed. " +
"The requested model may be temporarily unavailable.\n" +
"Logs: openclaw logs --follow"
: "⚠️ Agent failed before reply: model switch could not be completed. " +
"The requested model may be temporarily unavailable. Please try again shortly.";
: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel)
? "⚠️ Agent failed before reply: model switch could not be completed. " +
"The requested model may be temporarily unavailable. Please try again shortly."
: "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly.";
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
text: switchErrorText,
text: resolveExternalRunFailureTextForConversation({
text: switchErrorText,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: !shouldSurfaceToControlUi,
}),
},
};
}
@@ -1637,6 +1691,17 @@ export async function runAgentTurnWithFallback(params: {
? sanitizeUserFacingText(message, { errorContext: true })
: message;
const trimmedMessage = safeMessage.replace(/\.\s*$/, "");
const externalRunFailureReply =
!isBilling &&
!(isRateLimit && !isOverloadedErrorMessage(message)) &&
!rateLimitOrOverloadedCopy &&
!isContextOverflow &&
!isRoleOrderingError &&
!shouldSurfaceToControlUi
? buildExternalRunFailureReply(message, {
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
})
: undefined;
const fallbackText = isBilling
? BILLING_ERROR_USER_MESSAGE
: isRateLimit && !isOverloadedErrorMessage(message)
@@ -1649,13 +1714,18 @@ export async function runAgentTurnWithFallback(params: {
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: shouldSurfaceToControlUi
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
: buildExternalRunFailureText(message);
: (externalRunFailureReply?.text ?? GENERIC_EXTERNAL_RUN_FAILURE_TEXT);
const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
text: fallbackText,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
});
params.replyOperation?.fail("run_failed", err);
return {
kind: "final",
payload: {
text: fallbackText,
text: userVisibleFallbackText,
},
};
}