diff --git a/extensions/reef/src/channel.test.ts b/extensions/reef/src/channel.test.ts new file mode 100644 index 000000000000..fa7731333ed0 --- /dev/null +++ b/extensions/reef/src/channel.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { resolveReefInboundDispatchContent } from "./inbound.js"; + +describe("Reef inbound dispatch content", () => { + it("keeps provenance model-visible without storing it in the transcript body", () => { + const content = resolveReefInboundDispatchContent({ + id: "message-1", + peer: "clanky", + text: "hello from Clanky", + provenance: "Untrusted third-party data from @clanky's agent.", + autonomy: "bounded", + }); + + expect(content).toEqual({ + rawBody: "hello from Clanky", + extraContext: { + UntrustedContext: ["Untrusted third-party data from @clanky's agent."], + ReefProvenance: "Untrusted third-party data from @clanky's agent.", + ReefEnvelopeId: "message-1", + SenderIsBot: true, + }, + }); + }); +}); diff --git a/extensions/reef/src/channel.ts b/extensions/reef/src/channel.ts index 1400f9394235..940fc33b8925 100644 --- a/extensions/reef/src/channel.ts +++ b/extensions/reef/src/channel.ts @@ -19,6 +19,7 @@ import { } from "./config-schema.js"; import { createConfiguredGuard, ReefMessageFlow } from "./flow.js"; import { ReefFriendManager } from "./friends.js"; +import { resolveReefInboundDispatchContent } from "./inbound.js"; import { reefMessageAdapter, reefOutboundAdapter } from "./outbound.js"; import { getActiveReef, getOptionalReefRuntime, getReefRuntime, setActiveReef } from "./runtime.js"; import { reefSetupAdapter, reefSetupWizard } from "./setup.js"; @@ -197,6 +198,7 @@ export const reefPlugin: ChannelPlugin = { }, }); const onIngress = async (message: ReefIngressMessage) => { + const dispatchContent = resolveReefInboundDispatchContent(message); const budget = autonomyBudget(message.autonomy); const loop = recordChannelBotPairLoopAndCheckSuppression({ scopeId: "reef:default", @@ -223,15 +225,9 @@ export const reefPlugin: ChannelPlugin = { senderAddress: `reef:${message.peer}`, recipientAddress: `reef:${ctx.account.config.handle}`, conversationLabel: `@${message.peer}'s agent`, - rawBody: message.text, - bodyForAgent: `${message.provenance}\n\n${message.text}`, + ...dispatchContent, messageId: message.id, commandAuthorized: false, - extraContext: { - ReefProvenance: message.provenance, - ReefEnvelopeId: message.id, - SenderIsBot: true, - }, deliver: async (payload) => { const text = payload && typeof payload === "object" && "text" in payload diff --git a/extensions/reef/src/inbound.ts b/extensions/reef/src/inbound.ts new file mode 100644 index 000000000000..201278585dd8 --- /dev/null +++ b/extensions/reef/src/inbound.ts @@ -0,0 +1,13 @@ +import type { ReefIngressMessage } from "./types.js"; + +export function resolveReefInboundDispatchContent(message: ReefIngressMessage) { + return { + rawBody: message.text, + extraContext: { + UntrustedContext: [message.provenance], + ReefProvenance: message.provenance, + ReefEnvelopeId: message.id, + SenderIsBot: true, + }, + }; +} diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index 55398c123858..21ebfa6bb5ae 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -27,7 +27,10 @@ import { import { isOpenClawDeliveryMirrorAssistantMessage } from "../shared/transcript-only-openclaw-assistant.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; -import { isSuppressedControlReplyText } from "./control-reply-text.js"; +import { + isSuppressedControlReplyText, + stripSuppressedControlReplyToken, +} from "./control-reply-text.js"; export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000; @@ -635,20 +638,46 @@ function sanitizeChatHistoryMessage( } } + const stripAssistantControlTokens = + role === "assistant" && !shouldPreserveAssistantControlReplyText(entry); + if (typeof entry.content === "string") { const stripped = stripInlineDirectiveTagsForDisplay(entry.content); + const controlStripped = stripAssistantControlTokens + ? stripSuppressedControlReplyToken(stripped.text) + : stripped.text; + changed ||= controlStripped !== stripped.text; if (preserveExactToolPayload) { - entry.content = stripped.text; + entry.content = controlStripped; changed ||= stripped.changed; } else { - const res = truncateChatHistoryText(stripped.text, maxChars); + const res = truncateChatHistoryText(controlStripped, maxChars); entry.content = res.text; changed ||= stripped.changed || res.truncated; } } else if (Array.isArray(entry.content)) { - const updated = entry.content.map((block) => - sanitizeChatHistoryContentBlock(block, { preserveExactToolPayload, maxChars }), - ); + const updated = entry.content.map((block) => { + const sanitized = sanitizeChatHistoryContentBlock(block, { + preserveExactToolPayload, + maxChars, + }); + if ( + !stripAssistantControlTokens || + !sanitized.block || + typeof sanitized.block !== "object" || + Array.isArray(sanitized.block) + ) { + return sanitized; + } + const contentBlock = sanitized.block as { type?: unknown; text?: unknown }; + if (!isAssistantTextContentType(contentBlock.type) || typeof contentBlock.text !== "string") { + return sanitized; + } + const text = stripSuppressedControlReplyToken(contentBlock.text); + return text === contentBlock.text + ? sanitized + : { block: { ...contentBlock, text }, changed: true }; + }); if (updated.some((item) => item.changed)) { entry.content = updated.map((item) => item.block); changed = true; @@ -673,11 +702,15 @@ function sanitizeChatHistoryMessage( if (typeof entry.text === "string") { const stripped = stripInlineDirectiveTagsForDisplay(entry.text); + const controlStripped = stripAssistantControlTokens + ? stripSuppressedControlReplyToken(stripped.text) + : stripped.text; + changed ||= controlStripped !== stripped.text; if (preserveExactToolPayload) { - entry.text = stripped.text; + entry.text = controlStripped; changed ||= stripped.changed; } else { - const res = truncateChatHistoryText(stripped.text, maxChars); + const res = truncateChatHistoryText(controlStripped, maxChars); entry.text = res.text; changed ||= stripped.changed || res.truncated; } @@ -710,6 +743,9 @@ function extractAssistantTextForSilentCheck(message: unknown): string | undefine return undefined; } const typed = block as { type?: unknown; text?: unknown }; + if (isAssistantInternalReasoningContentType(typed.type)) { + continue; + } if (!isAssistantTextContentType(typed.type) || typeof typed.text !== "string") { return undefined; } @@ -722,6 +758,10 @@ function isAssistantTextContentType(type: unknown): boolean { return type === "text" || type === "input_text" || type === "output_text"; } +function isAssistantInternalReasoningContentType(type: unknown): boolean { + return type === "thinking" || type === "reasoning" || type === "redacted_thinking"; +} + function hasAssistantNonTextContent(message: unknown): boolean { if (!message || typeof message !== "object") { return false; @@ -738,6 +778,51 @@ function hasAssistantNonTextContent(message: unknown): boolean { ); } +function hasAssistantDisplayableNonTextContent(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return false; + } + return content.some( + (block) => + block && + typeof block === "object" && + !isAssistantTextContentType((block as { type?: unknown }).type) && + !isAssistantInternalReasoningContentType((block as { type?: unknown }).type), + ); +} + +function shouldPreserveAssistantControlReplyText(message: Record): boolean { + if (isProjectedSessionsSendForwardedMessage(message)) { + return true; + } + const content = message.text ?? message.content; + const texts = + typeof content === "string" + ? [content] + : Array.isArray(content) + ? content.flatMap((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return []; + } + const typed = block as { type?: unknown; text?: unknown }; + return isAssistantTextContentType(typed.type) && typeof typed.text === "string" + ? [typed.text] + : []; + }) + : []; + return ( + texts.length > 0 && + texts.every((text) => + isSuppressedControlReplyText(stripInlineDirectiveTagsForDisplay(text).text), + ) && + hasAssistantDisplayableNonTextContent(message) + ); +} + function hasAssistantMixedToolVisibleText(message: unknown): boolean { if (!message || typeof message !== "object") { return false; @@ -927,7 +1012,9 @@ function extractMessageToolVisibleReplies( function isAssistantSilentControlReplyOnly(message: Record): boolean { const text = extractAssistantTextForSilentCheck(message); return ( - text !== undefined && isSuppressedControlReplyText(text) && !hasAssistantNonTextContent(message) + text !== undefined && + isSuppressedControlReplyText(text) && + !hasAssistantDisplayableNonTextContent(message) ); } @@ -1240,10 +1327,14 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean { return !hasAssistantMixedToolVisibleText(message); } const text = extractAssistantTextForSilentCheck(message); - if (text === undefined || !isSuppressedControlReplyText(text)) { + // Classify after removing UI-only directives, before sanitization can erase + // the control token and leave a blank assistant row behind. + const displayText = + text === undefined ? undefined : stripInlineDirectiveTagsForDisplay(text).text; + if (displayText === undefined || !isSuppressedControlReplyText(displayText)) { return false; } - return !hasAssistantNonTextContent(message); + return !hasAssistantDisplayableNonTextContent(message); } export function sanitizeChatHistoryMessages( diff --git a/src/gateway/control-reply-text.test.ts b/src/gateway/control-reply-text.test.ts new file mode 100644 index 000000000000..579794355087 --- /dev/null +++ b/src/gateway/control-reply-text.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { projectChatDisplayMessages } from "./chat-display-projection.js"; +import { stripSuppressedControlReplyToken } from "./control-reply-text.js"; +import { projectLiveAssistantBufferedText } from "./live-chat-projector.js"; + +describe("control reply display projection", () => { + it("preserves text whitespace when no control token is present", () => { + expect(stripSuppressedControlReplyToken(" keep padded ")).toBe(" keep padded "); + expect( + projectChatDisplayMessages([ + { role: "assistant", content: [{ type: "text", text: " keep padded " }] }, + ]), + ).toEqual([{ role: "assistant", content: [{ type: "text", text: " keep padded " }] }]); + }); + + it("preserves control-looking text when it accompanies displayable content", () => { + const message = { + role: "assistant", + content: [ + { type: "text", text: "NO_REPLY" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, + ], + }; + + expect(stripSuppressedControlReplyToken("NO_REPLY")).toBe(""); + expect(projectChatDisplayMessages([message])).toEqual([message]); + }); + + it("strips a standalone control token beside visible text", () => { + expect( + projectChatDisplayMessages([ + { + role: "assistant", + content: [ + { type: "text", text: "Visible reply" }, + { type: "text", text: "NO_REPLY" }, + ], + }, + ]), + ).toEqual([ + { + role: "assistant", + content: [ + { type: "text", text: "Visible reply" }, + { type: "text", text: "" }, + ], + }, + ]); + }); + + it("preserves control-looking text forwarded from another session", () => { + const message = { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + provenance: { + kind: "inter_session", + sourceSessionKey: "agent:main:webchat:source", + sourceTool: "sessions_send", + }, + }; + + expect(projectChatDisplayMessages([message])).toEqual([message]); + }); + + it("strips a trailing sessions control token from substantive text", () => { + const text = "The handoff is complete.\n\nREPLY_SKIP"; + + expect(stripSuppressedControlReplyToken(text)).toBe("The handoff is complete."); + expect(projectLiveAssistantBufferedText(text)).toEqual({ + text: "The handoff is complete.", + suppress: false, + pendingLeadFragment: false, + }); + expect( + projectChatDisplayMessages([{ role: "assistant", content: [{ type: "text", text }] }]), + ).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "The handoff is complete." }], + }, + ]); + }); + + it("strips a trailing control token after removing inline directives", () => { + expect( + projectChatDisplayMessages([ + { + role: "assistant", + content: [ + { + type: "text", + text: "The handoff is complete.\n\nREPLY_SKIP [[audio_as_voice]]", + }, + ], + }, + ]), + ).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "The handoff is complete." }], + }, + ]); + }); + + it("hides a control-only reply with an inline directive", () => { + expect( + projectChatDisplayMessages([ + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY [[audio_as_voice]]" }], + }, + ]), + ).toEqual([]); + }); + + it("hides a control-only reply that also contains model thinking", () => { + expect( + projectChatDisplayMessages([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "The loop is complete." }, + { type: "text", text: "REPLY_SKIP" }, + ], + }, + ]), + ).toEqual([]); + }); +}); diff --git a/src/gateway/control-reply-text.ts b/src/gateway/control-reply-text.ts index ed61fe7cbda7..45cdaf5f1609 100644 --- a/src/gateway/control-reply-text.ts +++ b/src/gateway/control-reply-text.ts @@ -1,6 +1,6 @@ // Gateway control-reply text classifier. // Suppresses internal auto-reply tokens before they leak to chat surfaces. -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN, stripSilentToken } from "../auto-reply/tokens.js"; const SUPPRESSED_CONTROL_REPLY_TOKENS = [ SILENT_REPLY_TOKEN, @@ -36,6 +36,21 @@ export function isSuppressedControlReplyText(text: string): boolean { return SUPPRESSED_CONTROL_REPLY_TOKENS.some((token) => isSilentReplyText(normalized, token)); } +/** Remove internal control tokens when a model appends one to visible reply text. */ +export function stripSuppressedControlReplyToken(text: string): string { + if (isSuppressedControlReplyText(text)) { + return ""; + } + let stripped = text; + for (const token of SUPPRESSED_CONTROL_REPLY_TOKENS) { + const next = stripSilentToken(stripped, token); + if (next !== stripped.trim()) { + stripped = next; + } + } + return stripped; +} + /** * Return true when streamed assistant text looks like the leading fragment of a control token. */ diff --git a/src/gateway/live-chat-projector.ts b/src/gateway/live-chat-projector.ts index 45fdc6b1be8a..919c82718a7c 100644 --- a/src/gateway/live-chat-projector.ts +++ b/src/gateway/live-chat-projector.ts @@ -12,6 +12,7 @@ import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isSuppressedControlReplyLeadFragment, isSuppressedControlReplyText, + stripSuppressedControlReplyToken, } from "./control-reply-text.js"; const MAX_LIVE_CHAT_BUFFER_CHARS = 500_000; @@ -87,9 +88,13 @@ export function projectLiveAssistantBufferedText( if (options?.suppressLeadFragments !== false && isSuppressedControlReplyLeadFragment(rawText)) { return { text: rawText, suppress: true, pendingLeadFragment: true }; } - const text = startsWithSilentToken(rawText, SILENT_REPLY_TOKEN) - ? stripLeadingSilentToken(rawText, SILENT_REPLY_TOKEN) - : rawText; + const withoutTrailingControlToken = stripSuppressedControlReplyToken(rawText); + if (!withoutTrailingControlToken) { + return { text: "", suppress: true, pendingLeadFragment: false }; + } + const text = startsWithSilentToken(withoutTrailingControlToken, SILENT_REPLY_TOKEN) + ? stripLeadingSilentToken(withoutTrailingControlToken, SILENT_REPLY_TOKEN) + : withoutTrailingControlToken; if (!text || isSuppressedControlReplyText(text)) { return { text: "", suppress: true, pendingLeadFragment: false }; }