fix(pi-embedded): strip [tool calls omitted] from user-facing text (#74578)

* fix(pi-embedded): strip [tool calls omitted] from user-facing text

The internal replay placeholder '[tool calls omitted]' was leaking
into channel output (e.g. Telegram) after aborted tool calls.

Fix: strip the placeholder early in sanitizeUserFacingText so all
channels are protected by default. The replay transcript path in
turns.ts is unaffected — it uses the placeholder internally.

Fixes #74573.

Signed-off-by: Blasius Patrick <blasius.patrick@gmail.com>

* fix(pi-embedded): preserve whitespace when stripping placeholder

* test(pi-embedded): document replay placeholder sanitization

* fix(pi-embedded): strip consecutive replay placeholders

---------

Signed-off-by: Blasius Patrick <blasius.patrick@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Blasius Patrick
2026-04-30 06:20:19 +07:00
committed by GitHub
parent 845dd2a7d5
commit d30b8dccfd
3 changed files with 39 additions and 7 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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);
}