Skip to content

[Feature] textTransforms should apply output replacements to toolcall_delta / toolcall_end events #97761

Description

@get-viti

Problem

ProviderPlugin.textTransforms supports bidirectional text replacement — input replacements mask text before it reaches the LLM, and output replacements restore text in the response stream. This works correctly for:

  • text_delta (streaming text chunks) ✅
  • text_end (final text content) ✅
  • partial (thinking/reasoning blocks) ✅
  • message (full message objects) ✅
  • error (error messages) ✅

However, toolcall_delta and toolcall_end events are not processed by output replacements. This means any transformed tokens in tool call arguments are passed verbatim to tool execution without being restored.

Impact

This is critical for PII anonymization and any text transform use case where the LLM generates tool calls that include transformed content:

  1. PII masking: Entity names are correctly encoded before reaching the LLM (e.g., "John Smith""[PER_001]"). The LLM then generates tool calls (e.g., send_message, create_card) using the encoded tokens. Since toolcall_delta/toolcall_end events bypass output transforms, the tool executes with [PER_001] instead of "John Smith" — resulting in encoded tokens appearing in external systems (Slack, Google Chat, email, etc.).

  2. Any output transform: The same gap applies to any plugin using textTransforms.output where the LLM's response includes tool calls with transformed text.

Reproduction

  1. Register a provider plugin with textTransforms:
textTransforms: {
  input: [{ from: /John Smith/g, to: "[MASKED]" }],
  output: [{ from: /\[MASKED\]/g, to: "John Smith" }]
}
  1. Ask the agent to send a message to a chat space mentioning "John Smith"
  2. The message arrives in the chat space containing [MASKED] instead of John Smith

Root Cause

In plugin-text-transforms (transformAssistantEventText), the function handles text_delta, text_end, partial, message, and error event types, but does not handle toolcall_delta or toolcall_end:

function transformAssistantEventText(event, replacements) {
  if (!isRecord(event) || !replacements || replacements.length === 0) return event;
  const next = { ...event };
  if (next.type === "text_delta" && typeof next.delta === "string")
    next.delta = applyPluginTextReplacements(next.delta, replacements);
  if (next.type === "text_end" && typeof next.content === "string")
    next.content = applyPluginTextReplacements(next.content, replacements);
  if (Object.hasOwn(next, "partial")) next.partial = transformMessageText(next.partial, replacements);
  if (Object.hasOwn(next, "message")) next.message = transformMessageText(next.message, replacements);
  if (Object.hasOwn(next, "error")) next.error = transformMessageText(next.error, replacements);
  // ← toolcall_delta and toolcall_end not handled
  return next;
}

Proposed Fix

Add output transform handling for tool call argument strings:

// After the existing text_end handler:
if (next.type === "toolcall_delta" && typeof next.argumentsDelta === "string")
  next.argumentsDelta = applyPluginTextReplacements(next.argumentsDelta, replacements);
if (next.type === "toolcall_end" && typeof next.arguments === "string")
  next.arguments = applyPluginTextReplacements(next.arguments, replacements);

This is ~4 lines in transformAssistantEventText and follows the exact same pattern as the existing text_delta/text_end handlers.

Environment

  • OpenClaw Gateway (Docker, latest image)
  • Provider: DeepSeek via textTransforms on provider definition
  • Confirmed encoding works on all other touchpoints (system prompt, messages, tool results, text responses, thinking blocks)

Additional Context

The textTransforms system is otherwise excellent — provider-level transforms correctly survive registry swaps during agent turns, and the bidirectional input/output contract works perfectly for all non-tool-call response events. This is the only remaining gap in the pipeline.

Metadata

Metadata

Assignees

Labels

P2Normal backlog priority with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.no-staleExclude from stale automation

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions