Skip to content

Commit d30b8dc

Browse files
blaspatsteipete
andauthored
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>
1 parent 845dd2a commit d30b8dc

3 files changed

Lines changed: 39 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
### Fixes
3030

3131
- 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.
32+
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.
3233
- 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.
3334
- 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.
3435
- 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.

src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,21 @@ describe("sanitizeUserFacingText", () => {
208208
expect(sanitizeUserFacingText("Line 1\nLine 2")).toBe("Line 1\nLine 2");
209209
});
210210

211+
it("strips tool-call replay placeholders without trimming visible text", () => {
212+
expect(sanitizeUserFacingText("[tool calls omitted]")).toBe("");
213+
expect(sanitizeUserFacingText(" [tool calls omitted]\t")).toBe("");
214+
expect(sanitizeUserFacingText("Hello\n\n[tool calls omitted]\nWorld\n")).toBe(
215+
"Hello\n\nWorld\n",
216+
);
217+
expect(sanitizeUserFacingText("A\n[tool calls omitted]\n[tool calls omitted]\nB")).toBe("A\nB");
218+
});
219+
220+
it("keeps ordinary inline mentions of the replay placeholder", () => {
221+
expect(sanitizeUserFacingText("What does [tool calls omitted] mean?")).toBe(
222+
"What does [tool calls omitted] mean?",
223+
);
224+
});
225+
211226
it("strips marked internal runtime context blocks but keeps real reply text", () => {
212227
const input = [
213228
INTERNAL_RUNTIME_CONTEXT_BEGIN,

src/agents/pi-embedded-helpers/sanitize-user-facing-text.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const MODEL_CAPACITY_ERROR_USER_MESSAGE =
4141
const OVERLOADED_ERROR_USER_MESSAGE =
4242
"The AI service is temporarily overloaded. Please try again in a moment.";
4343
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
44+
const TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE = /^[ \t]*\[tool calls omitted\][ \t]*$/i;
4445
const ERROR_PREFIX_RE =
4546
/^(?: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;
4647
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
@@ -332,6 +333,22 @@ function stripFinalTagsFromText(text: unknown): string {
332333
return normalized.replace(FINAL_TAG_RE, "");
333334
}
334335

336+
function stripToolCallsOmittedPlaceholderLines(text: string): string {
337+
let result = "";
338+
let start = 0;
339+
while (start < text.length) {
340+
const newlineIndex = text.indexOf("\n", start);
341+
const end = newlineIndex === -1 ? text.length : newlineIndex + 1;
342+
const chunk = text.slice(start, end);
343+
const line = chunk.endsWith("\n") ? chunk.slice(0, -1).replace(/\r$/, "") : chunk;
344+
if (!TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE.test(line)) {
345+
result += chunk;
346+
}
347+
start = end;
348+
}
349+
return result;
350+
}
351+
335352
function collapseConsecutiveDuplicateBlocks(text: string): string {
336353
const trimmed = text.trim();
337354
if (!trimmed) {
@@ -383,15 +400,18 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
383400
}
384401
const errorContext = opts?.errorContext ?? false;
385402
const stripped = stripInboundMetadata(stripInternalRuntimeContext(stripFinalTagsFromText(raw)));
386-
const trimmed = stripped.trim();
403+
// Replay repair may synthesize this placeholder to keep provider transcripts valid.
404+
// It is internal scaffolding, so drop standalone placeholder lines before delivery
405+
// while preserving ordinary inline mentions a user may be discussing.
406+
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(stripped);
407+
const trimmed = withoutPlaceholder.trim();
387408
if (!trimmed) {
388409
return "";
389410
}
390411

391412
if (!errorContext && shouldRewriteRawPayloadWithoutErrorContext(trimmed)) {
392413
return formatRawAssistantErrorForUi(trimmed);
393414
}
394-
395415
if (errorContext) {
396416
const execDeniedMessage = formatExecDeniedUserMessage(trimmed);
397417
if (execDeniedMessage) {
@@ -422,19 +442,15 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
422442
if (isBillingErrorMessage(trimmed)) {
423443
return BILLING_ERROR_USER_MESSAGE;
424444
}
425-
426445
if (isInvalidStreamingEventOrderError(trimmed)) {
427446
return "LLM request failed: provider returned an invalid streaming response. Please try again.";
428447
}
429-
430448
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
431449
return formatRawAssistantErrorForUi(trimmed);
432450
}
433-
434451
if (isStreamingJsonParseError(trimmed)) {
435452
return "LLM streaming response contained a malformed fragment. Please try again.";
436453
}
437-
438454
if (ERROR_PREFIX_RE.test(trimmed)) {
439455
const prefixedCopy = formatRateLimitOrOverloadedErrorCopy(trimmed);
440456
if (prefixedCopy) {
@@ -451,6 +467,6 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
451467
}
452468
}
453469

454-
const withoutLeadingEmptyLines = stripped.replace(/^(?:[ \t]*\r?\n)+/, "");
470+
const withoutLeadingEmptyLines = withoutPlaceholder.replace(/^(?:[ \t]*\r?\n)+/, "");
455471
return collapseConsecutiveDuplicateBlocks(withoutLeadingEmptyLines);
456472
}

0 commit comments

Comments
 (0)