diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f86f884c3f..ad3a7c1533e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc. +- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat. - Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79. - Gateway/diagnostics: include stuck-session reason hints and recovery skip causes in warnings, so operators can tell whether a lane is waiting on active work, queued work, or stale bookkeeping. Thanks @vincentkoc. - Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc. diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index 1f6be125886..e8aa4629ad4 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -208,6 +208,21 @@ describe("sanitizeUserFacingText", () => { expect(sanitizeUserFacingText("Line 1\nLine 2")).toBe("Line 1\nLine 2"); }); + it("strips tool-call replay placeholders without trimming visible text", () => { + expect(sanitizeUserFacingText("[tool calls omitted]")).toBe(""); + expect(sanitizeUserFacingText(" [tool calls omitted]\t")).toBe(""); + expect(sanitizeUserFacingText("Hello\n\n[tool calls omitted]\nWorld\n")).toBe( + "Hello\n\nWorld\n", + ); + expect(sanitizeUserFacingText("A\n[tool calls omitted]\n[tool calls omitted]\nB")).toBe("A\nB"); + }); + + it("keeps ordinary inline mentions of the replay placeholder", () => { + expect(sanitizeUserFacingText("What does [tool calls omitted] mean?")).toBe( + "What does [tool calls omitted] mean?", + ); + }); + it("strips marked internal runtime context blocks but keeps real reply text", () => { const input = [ INTERNAL_RUNTIME_CONTEXT_BEGIN, diff --git a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts b/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts index a6037a05b5b..7c3553ec137 100644 --- a/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts +++ b/src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts @@ -41,6 +41,7 @@ const MODEL_CAPACITY_ERROR_USER_MESSAGE = const OVERLOADED_ERROR_USER_MESSAGE = "The AI service is temporarily overloaded. Please try again in a moment."; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; +const TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE = /^[ \t]*\[tool calls omitted\][ \t]*$/i; const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; const CONTEXT_OVERFLOW_ERROR_HEAD_RE = @@ -332,6 +333,22 @@ function stripFinalTagsFromText(text: unknown): string { return normalized.replace(FINAL_TAG_RE, ""); } +function stripToolCallsOmittedPlaceholderLines(text: string): string { + let result = ""; + let start = 0; + while (start < text.length) { + const newlineIndex = text.indexOf("\n", start); + const end = newlineIndex === -1 ? text.length : newlineIndex + 1; + const chunk = text.slice(start, end); + const line = chunk.endsWith("\n") ? chunk.slice(0, -1).replace(/\r$/, "") : chunk; + if (!TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE.test(line)) { + result += chunk; + } + start = end; + } + return result; +} + function collapseConsecutiveDuplicateBlocks(text: string): string { const trimmed = text.trim(); if (!trimmed) { @@ -383,7 +400,11 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo } const errorContext = opts?.errorContext ?? false; const stripped = stripInboundMetadata(stripInternalRuntimeContext(stripFinalTagsFromText(raw))); - const trimmed = stripped.trim(); + // Replay repair may synthesize this placeholder to keep provider transcripts valid. + // It is internal scaffolding, so drop standalone placeholder lines before delivery + // while preserving ordinary inline mentions a user may be discussing. + const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(stripped); + const trimmed = withoutPlaceholder.trim(); if (!trimmed) { return ""; } @@ -391,7 +412,6 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo if (!errorContext && shouldRewriteRawPayloadWithoutErrorContext(trimmed)) { return formatRawAssistantErrorForUi(trimmed); } - if (errorContext) { const execDeniedMessage = formatExecDeniedUserMessage(trimmed); if (execDeniedMessage) { @@ -422,19 +442,15 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo if (isBillingErrorMessage(trimmed)) { return BILLING_ERROR_USER_MESSAGE; } - if (isInvalidStreamingEventOrderError(trimmed)) { return "LLM request failed: provider returned an invalid streaming response. Please try again."; } - if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { return formatRawAssistantErrorForUi(trimmed); } - if (isStreamingJsonParseError(trimmed)) { return "LLM streaming response contained a malformed fragment. Please try again."; } - if (ERROR_PREFIX_RE.test(trimmed)) { const prefixedCopy = formatRateLimitOrOverloadedErrorCopy(trimmed); if (prefixedCopy) { @@ -451,6 +467,6 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo } } - const withoutLeadingEmptyLines = stripped.replace(/^(?:[ \t]*\r?\n)+/, ""); + const withoutLeadingEmptyLines = withoutPlaceholder.replace(/^(?:[ \t]*\r?\n)+/, ""); return collapseConsecutiveDuplicateBlocks(withoutLeadingEmptyLines); }