mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix: hide raw agent failures in group chats
(cherry picked from commit 1969452c3f)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user