mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user