diff --git a/CHANGELOG.md b/CHANGELOG.md index c5704d8b0e2..a73ff62d382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 7d6ee3876af..8aac5a0cf4b 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -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..silentReply` and `surfaces..silentReplyRewrite` can override them per surface. diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index aa104d716ac..63926840ce1 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -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"); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 35c3e1ec3e9..e0ac690a51e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, }, }; }