Skip to content

feat(cua-driver-rs): strip trailing agent-protocol closing tags from type_text payloads - #1695

Merged
f-trycua merged 1 commit into
mainfrom
cua-driver-rs-strip-agent-protocol-tags
May 25, 2026
Merged

feat(cua-driver-rs): strip trailing agent-protocol closing tags from type_text payloads#1695
f-trycua merged 1 commit into
mainfrom
cua-driver-rs-strip-agent-protocol-tags

Conversation

@f-trycua

@f-trycua f-trycua commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

When an LLM agent calls `type_text` with a long multi-line body it occasionally hallucinates its own tool-invocation closing tags into the trailing whitespace — text / parameter / invoke / function_calls closing tags from Anthropic's tool format leak past the parameter boundary. The MCP transport strips them at the protocol layer but the VALUE of the `text` field still contains them. They get faithfully delivered to the target window and the agent often can't recover (Backspace is blocked on VCL/SAL targets under background dispatch).

This PR adds a small defensive sanitizer that strips a trailing run of recognized agent-protocol closing tags before the text leaves the tool layer.

Demonstrated regression

While writing a design doc on LibreOffice (via vision-only mode), my own `type_text` call accidentally trailed the protocol closing tags into the document. They showed up as the last two lines of the saved doc and couldn't be removed — backspace on VCL Writer is blocked by the SAL keystroke guard under `dispatch:"background"`.

Three guardrails against false positives

  1. Only tags in the known protocol set are eligible (text, parameter, invoke, function_calls, function_call, tool_use, tool_call, plus the antml-namespaced variants). Generic HTML closings (div / span / etc.) are left alone.
  2. Only tags at the very end of the string (after trimming trailing whitespace) are eligible. Inline tags in legit document content are untouched.
  3. Balance check: an unbalanced closing is stripped; a closing with a matching opener earlier in the body is kept. So legitimately-balanced content survives, while a trailing run of orphaned closing tags is removed.

Returns `Cow::Borrowed` on the no-match fast path so the common case is allocation-free. Emits a `tracing::warn` when sanitization fires.

Call sites

Wired into all 6 `type_text` invocations:

  • `platform-windows/src/tools/impl_.rs` — TypeTextTool, TypeTextCharsTool
  • `platform-macos/src/tools/type_text.rs`
  • `platform-macos/src/tools/type_text_chars.rs`
  • `platform-linux/src/tools/impl_.rs` — TypeTextTool, TypeTextCharsTool

Test plan

  • `cargo test -p mcp-server text_sanitize` — 8/8 passing
  • `cargo build --release -p cua-driver` — clean

Tests use a placeholder-substitution helper (`{C}` → "[/text]" etc.) so the test source can be written and edited via an LLM-driven tool chain without the literal sequences tripping on the parent tool boundary.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Text input tools now automatically sanitize and remove trailing protocol artifacts that could interfere with text typing operations.
  • Improvements

    • Enhanced text input handling across all platforms (Linux, macOS, Windows) for more robust character and text injection.

Review Change Stack

…sing tags from type_text payloads

When an LLM agent calls `type_text` with a long multi-line text body it
occasionally hallucinates its own tool-invocation closing tags into the
trailing whitespace - Anthropic's tool format closing tags for text,
parameter, invoke, function_calls (shown here in prose form to keep the
sanitizer from recursively eating its own commit message). The MCP
transport correctly strips them at the protocol layer but the VALUE of
the `text` field still contains them; they get faithfully delivered to
the target window and the agent often can't recover (Backspace is
blocked on VCL targets under background dispatch, etc.).

This change adds a small defensive sanitizer in `mcp_server::text_sanitize`
that strips a trailing run of recognized agent-protocol closing tags
before the text leaves the tool layer. Three guardrails against false
positives:

  1. Only tags in the known protocol set are eligible (text, parameter,
     invoke, function_calls, function_call, tool_use, tool_call, plus
     the antml-namespaced variants). Generic HTML closings (div/span/
     etc.) are left alone.
  2. Only tags at the very end (after trimming trailing whitespace) are
     eligible. Inline tags in the middle of legitimate document content
     are untouched.
  3. Balance check: an unbalanced closing is stripped; a closing with a
     matching opener earlier in the body is kept. So document content
     like "[text]foo[/text]" (balanced) survives, while a trailing
     "[/text][/invoke]" (no openers) is stripped.

Returns `Cow::Borrowed` on the no-match fast path so the common case is
allocation-free. Emits a `tracing::warn` when sanitization fires so
operators can see the rate at which agents stumble into this.

Wired into all 6 type_text call sites:

  - platform-windows/src/tools/impl_.rs (TypeTextTool, TypeTextCharsTool)
  - platform-macos/src/tools/type_text.rs
  - platform-macos/src/tools/type_text_chars.rs
  - platform-linux/src/tools/impl_.rs (TypeTextTool, TypeTextCharsTool)

8 unit tests, all green: strip-trailing-text-invoke-tail,
strip-with-antml-namespace, leave-balanced-text-alone,
leave-balanced-parameter-alone, leave-unrelated-html-alone,
leave-plain-text-alone, strip-only-trailing-keeps-inner-html,
empty-input-is-borrowed. Tests use placeholder substitution (`{C}` ->
"[/text]" etc.) so the test source can be written without the literal
sequences that would otherwise trip on the agent's own tool boundary
when editing the file via an LLM.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel

vercel Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored May 25, 2026 7:09pm

Request Review

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR adds defensive text sanitization to strip trailing agent-protocol closing tags from tool text inputs. A new text_sanitize module detects and removes unbalanced protocol tags at the end of strings, preserving balanced HTML and leaving plain text unchanged. The sanitization is integrated into Linux, macOS, and Windows typing tools.

Changes

Text Sanitization for Agent Protocol Hallucinations

Layer / File(s) Summary
Text sanitization module and contract
libs/cua-driver/rust/crates/mcp-server/src/lib.rs, libs/cua-driver/rust/crates/mcp-server/src/text_sanitize.rs
strip_trailing_agent_protocol_tags detects and removes trailing protocol closing tags (e.g., </text>, </invoke>) only when unbalanced and at string end. Module docs, tag constants, tag scanning logic, unbalanced-tag detection via case-insensitive opener search, warning on strip, and comprehensive unit tests for stripping behavior, namespace variants, and preservation of balanced tags.
Text sanitization integration across platforms
libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs, libs/cua-driver/rust/crates/platform-macos/src/tools/type_text.rs, libs/cua-driver/rust/crates/platform-macos/src/tools/type_text_chars.rs, libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs
Applies sanitization to TypeTextTool and TypeTextCharsTool on all three platforms by sanitizing the text argument before window resolution, routing decisions, character counting, and text injection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • trycua/cua#1597: Changes Windows TypeTextTool routing between PostMessage and UIA ValuePattern for XAML hosts; this PR sanitizes the same tool's text input before routing decisions.

Poem

🐰 Trailing tags caused quite a mess,
Agent whispers, I confess!
Now we strip them, clean and neat,
Balanced HTML stays complete. 🏷️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: adding sanitization to strip trailing agent-protocol closing tags from type_text payloads, which is the core focus of this PR across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cua-driver-rs-strip-agent-protocol-tags

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs (1)

666-670: ⚡ Quick win

Avoid unconditional clone on sanitizer fast path.

Both call sites clone the entire string on Cow::Borrowed via .into_owned(). Since text_raw is already owned, you can keep it without copying when no sanitization occurs.

Proposed change
-        let text = mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw)
-            .into_owned();
+        let text = match mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw) {
+            std::borrow::Cow::Borrowed(_) => text_raw,
+            std::borrow::Cow::Owned(s) => s,
+        };

Apply the same pattern at both Line 669 and Line 1993 call sites.

Also applies to: 1990-1994

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
666 - 670, The code unconditionally calls .into_owned() on the Cow returned by
mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw),
causing an unnecessary clone when the sanitizer returns Cow::Borrowed; change
both call sites (the one using args.require_str("text") -> text_raw and the
other at the 1990–1994 site) to match on the returned Cow and return the
existing owned String when the Cow is Borrowed (e.g., if Cow::Borrowed(_) -> use
text_raw) and use the owned String when Cow::Owned(s) -> s, so you avoid copying
the whole string on the sanitizer fast path while still returning a String.
libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs (1)

2110-2117: ⚡ Quick win

Preserve the sanitizer’s zero-allocation fast path

Both call sites immediately do .into_owned(), which forces an allocation even when the sanitizer returns Cow::Borrowed. That negates the performance contract documented in comments and in mcp_server::text_sanitize.

💡 Proposed fix
-        let text_raw = match args.require_str("text") { Ok(v) => v, Err(e) => return e };
+        let text_raw = match args.require_str("text") { Ok(v) => v, Err(e) => return e };
         // Strip trailing agent-protocol closing tags before delivery —
         // catches the case where an LLM hallucinated its own tool-
         // invocation tags into the text param (see text_sanitize docs).
-        // Returns Cow::Borrowed on the no-match fast path so the common
-        // case is allocation-free.
-        let text = mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw)
-            .into_owned();
+        // Preserve no-match fast path: reuse `text_raw` when Borrowed.
+        let text = match mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw) {
+            std::borrow::Cow::Borrowed(_) => text_raw,
+            std::borrow::Cow::Owned(s) => s,
+        };
-        let text_raw = match args.require_str("text") { Ok(v) => v, Err(e) => return e };
+        let text_raw = match args.require_str("text") { Ok(v) => v, Err(e) => return e };
         // Same trailing-protocol-tag scrub as the main TypeTextTool — see
         // mcp_server::text_sanitize for rationale.
-        let text = mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw)
-            .into_owned();
+        let text = match mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw) {
+            std::borrow::Cow::Borrowed(_) => text_raw,
+            std::borrow::Cow::Owned(s) => s,
+        };

Also applies to: 4586-4590

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs` around lines
2110 - 2117, The sanitizer strip_trailing_agent_protocol_tags returns a Cow to
preserve a zero-allocation fast path, but the code immediately calls
.into_owned() (see variables text_raw and text) which forces allocation; change
the call site to accept the Cow and only clone/into_owned when a mutable/owned
String is actually needed (e.g., work with the returned Cow<&str> from
mcp_server::text_sanitize::strip_trailing_agent_protocol_tags and call
to_string() or into_owned() only at the final point where ownership is
required). Apply the same change to the other identical call site that currently
calls .into_owned().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/cua-driver/rust/crates/mcp-server/src/text_sanitize.rs`:
- Around line 99-105: The current balance check using has_matching_opener can
stop too early when there is a legitimate opener/closer pair followed by an
orphaned trailing closer; modify the logic around trimmed_head, open and inner
to call a new has_unclosed_opener(preceding, inner) that counts occurrences of
the same tag name (opening vs closing) in preceding and returns true only if
open_count > close_count (i.e., there is an unmatched opener), replace the
has_matching_opener call with this count-aware check, and add a regression test
asserting that input "<text>ok</text></text>" becomes "<text>ok</text>" to
ensure the orphaned trailing closer is stripped.

---

Nitpick comments:
In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs`:
- Around line 666-670: The code unconditionally calls .into_owned() on the Cow
returned by
mcp_server::text_sanitize::strip_trailing_agent_protocol_tags(&text_raw),
causing an unnecessary clone when the sanitizer returns Cow::Borrowed; change
both call sites (the one using args.require_str("text") -> text_raw and the
other at the 1990–1994 site) to match on the returned Cow and return the
existing owned String when the Cow is Borrowed (e.g., if Cow::Borrowed(_) -> use
text_raw) and use the owned String when Cow::Owned(s) -> s, so you avoid copying
the whole string on the sanitizer fast path while still returning a String.

In `@libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs`:
- Around line 2110-2117: The sanitizer strip_trailing_agent_protocol_tags
returns a Cow to preserve a zero-allocation fast path, but the code immediately
calls .into_owned() (see variables text_raw and text) which forces allocation;
change the call site to accept the Cow and only clone/into_owned when a
mutable/owned String is actually needed (e.g., work with the returned Cow<&str>
from mcp_server::text_sanitize::strip_trailing_agent_protocol_tags and call
to_string() or into_owned() only at the final point where ownership is
required). Apply the same change to the other identical call site that currently
calls .into_owned().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e84851f-2acc-4c56-ad2f-ba1312c20371

📥 Commits

Reviewing files that changed from the base of the PR and between 892edd4 and a825852.

📒 Files selected for processing (6)
  • libs/cua-driver/rust/crates/mcp-server/src/lib.rs
  • libs/cua-driver/rust/crates/mcp-server/src/text_sanitize.rs
  • libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
  • libs/cua-driver/rust/crates/platform-macos/src/tools/type_text.rs
  • libs/cua-driver/rust/crates/platform-macos/src/tools/type_text_chars.rs
  • libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs

Comment on lines +99 to +105
// Balance check: is there an opening `<NAME` (case-insensitive,
// optionally followed by `>` or whitespace) earlier in the head?
// If so, treat the closing as legitimate document content and stop.
let preceding = &trimmed_head[..open];
if has_matching_opener(preceding, inner) {
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Balance guard can miss orphaned duplicate trailing closers.

If the body contains one legitimate pair and then an extra trailing closer (e.g., <text>x</text></text>), Line 103 sees an opener and stops, so the orphaned tail is not stripped.

Suggested fix direction
-        if has_matching_opener(preceding, inner) {
+        if has_unclosed_opener(preceding, inner) {
             break;
         }

Implement has_unclosed_opener as a count-aware check for the same tag name in preceding (open_count > close_count), and add a regression test for <text>ok</text></text> expecting "<text>ok</text>".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/mcp-server/src/text_sanitize.rs` around lines 99
- 105, The current balance check using has_matching_opener can stop too early
when there is a legitimate opener/closer pair followed by an orphaned trailing
closer; modify the logic around trimmed_head, open and inner to call a new
has_unclosed_opener(preceding, inner) that counts occurrences of the same tag
name (opening vs closing) in preceding and returns true only if open_count >
close_count (i.e., there is an unmatched opener), replace the
has_matching_opener call with this count-aware check, and add a regression test
asserting that input "<text>ok</text></text>" becomes "<text>ok</text>" to
ensure the orphaned trailing closer is stripped.

@f-trycua
f-trycua merged commit 95e003a into main May 25, 2026
6 of 7 checks passed
@f-trycua
f-trycua deleted the cua-driver-rs-strip-agent-protocol-tags branch May 25, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant