fix(agents): prevent false billing error replacing valid response text (#40616)

Merged via squash.

Prepared head SHA: 05179362b4
Co-authored-by: ingyukoh <6015960+ingyukoh@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
ingyukoh
2026-03-12 04:00:11 +09:00
committed by GitHub
parent 78b9384aa7
commit 2a18cbb110
7 changed files with 54 additions and 14 deletions

View File

@@ -107,6 +107,7 @@ Docs: https://docs.openclaw.ai
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh. - Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam. - Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh. - Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
## 2026.3.8 ## 2026.3.8

View File

@@ -101,6 +101,18 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads[0]?.isError).toBe(true); expect(payloads[0]?.isError).toBe(true);
}); });
it("does not emit a synthetic billing error for successful turns with stale errorMessage", () => {
const payloads = buildPayloads({
lastAssistant: makeAssistant({
stopReason: "stop",
errorMessage: "insufficient credits for embedding model",
content: [{ type: "text", text: "Handle payment required errors in your API." }],
}),
});
expectSinglePayloadText(payloads, "Handle payment required errors in your API.");
});
it("suppresses raw error JSON even when errorMessage is missing", () => { it("suppresses raw error JSON even when errorMessage is missing", () => {
const payloads = buildPayloads({ const payloads = buildPayloads({
assistantTexts: [errorJsonPretty], assistantTexts: [errorJsonPretty],

View File

@@ -128,16 +128,17 @@ export function buildEmbeddedRunPayloads(params: {
const useMarkdown = params.toolResultFormat === "markdown"; const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true; const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant const errorText =
? suppressAssistantArtifacts params.lastAssistant && lastAssistantErrored
? undefined ? suppressAssistantArtifacts
: formatAssistantErrorText(params.lastAssistant, { ? undefined
cfg: params.config, : formatAssistantErrorText(params.lastAssistant, {
sessionKey: params.sessionKey, cfg: params.config,
provider: params.provider, sessionKey: params.sessionKey,
model: params.model, provider: params.provider,
}) model: params.model,
: undefined; })
: undefined;
const rawErrorMessage = lastAssistantErrored const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined ? params.lastAssistant?.errorMessage?.trim() || undefined
: undefined; : undefined;

View File

@@ -134,6 +134,20 @@ describe("extractAssistantText", () => {
); );
}); });
it("preserves response when errorMessage set from background failure (#13935)", () => {
const responseText = "Handle payment required errors in your API.";
const msg = makeAssistantMessage({
role: "assistant",
errorMessage: "insufficient credits for embedding model",
stopReason: "stop",
content: [{ type: "text", text: responseText }],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe(responseText);
});
it("strips Minimax tool invocations with extra attributes", () => { it("strips Minimax tool invocations with extra attributes", () => {
const msg = makeAssistantMessage({ const msg = makeAssistantMessage({
role: "assistant", role: "assistant",

View File

@@ -245,7 +245,9 @@ export function extractAssistantText(msg: AssistantMessage): string {
}) ?? ""; }) ?? "";
// Only apply keyword-based error rewrites when the assistant message is actually an error. // Only apply keyword-based error rewrites when the assistant message is actually an error.
// Otherwise normal prose that *mentions* errors (e.g. "context overflow") can get clobbered. // Otherwise normal prose that *mentions* errors (e.g. "context overflow") can get clobbered.
const errorContext = msg.stopReason === "error" || Boolean(msg.errorMessage?.trim()); // Gate on stopReason only — a non-error response with an errorMessage set (e.g. from a
// background tool failure) should not have its content rewritten (#13935).
const errorContext = msg.stopReason === "error";
return sanitizeUserFacingText(extracted, { errorContext }); return sanitizeUserFacingText(extracted, { errorContext });
} }

View File

@@ -166,9 +166,9 @@ export function extractAssistantText(message: unknown): string | undefined {
normalizeText: (text) => text.trim(), normalizeText: (text) => text.trim(),
}) ?? ""; }) ?? "";
const stopReason = (message as { stopReason?: unknown }).stopReason; const stopReason = (message as { stopReason?: unknown }).stopReason;
const errorMessage = (message as { errorMessage?: unknown }).errorMessage; // Gate on stopReason only — a non-error response with a stale/background errorMessage
const errorContext = // should not have its content rewritten with error templates (#13935).
stopReason === "error" || (typeof errorMessage === "string" && Boolean(errorMessage.trim())); const errorContext = stopReason === "error";
return joined ? sanitizeUserFacingText(joined, { errorContext }) : undefined; return joined ? sanitizeUserFacingText(joined, { errorContext }) : undefined;
} }

View File

@@ -199,6 +199,16 @@ describe("extractAssistantText", () => {
"Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.",
); );
}); });
it("preserves successful turns with stale background errorMessage", () => {
const message = {
role: "assistant",
stopReason: "end_turn",
errorMessage: "insufficient credits for embedding model",
content: [{ type: "text", text: "Handle payment required errors in your API." }],
};
expect(extractAssistantText(message)).toBe("Handle payment required errors in your API.");
});
}); });
describe("resolveAnnounceTarget", () => { describe("resolveAnnounceTarget", () => {