Skip to content

Bound duplicated text-heavy tool output#85

Draft
Reneromero08 wants to merge 2 commits into
Waishnav:mainfrom
Reneromero08:fix/bounded-tool-output
Draft

Bound duplicated text-heavy tool output#85
Reneromero08 wants to merge 2 commits into
Waishnav:mainfrom
Reneromero08:fix/bounded-tool-output

Conversation

@Reneromero08

@Reneromero08 Reneromero08 commented Jul 19, 2026

Copy link
Copy Markdown

Problem

Text-heavy tools copied the same output into content, structuredContent.result, and the widget card payload. Large shell and process responses therefore amplified one result across model-visible transcript and UI channels.

Fix

  • Add a configurable DEVSPACE_INLINE_OUTPUT_CHARACTERS or inlineOutputCharacters ceiling, defaulting to 12000 characters.
  • Return one Unicode-safe bounded head and tail preview in content.
  • Replace duplicate structured output with a compact receipt and explicit size and truncation metadata.
  • Bound read, grep, glob, ls, bash, exec_command, and write_stdin success and error output.
  • Omit unused card payloads when widgets are changes or off.
  • Preserve executor truncation separately from inline-response truncation.
  • Document the setting and surface it in serve and doctor diagnostics.

Regression coverage

A deterministic 50000-character response test confirms the previous three-channel payload exceeds 150 KB while the bounded full-widget response stays below 30 KB and below one quarter of the original size.

Validation

  • npm run typecheck
  • npm test
  • npm run build
  • node dist/cli.js doctor reports Inline output characters: 12000
  • git diff --check

Summary by CodeRabbit

  • New Features
    • Added configurable inline previews for lengthy tool output, reducing oversized responses while preserving key metadata.
    • Added output details showing original size, preview size, omitted characters, and truncation status.
    • Added configuration through environment variables or the user configuration file.
    • Updated initialization, server startup, and diagnostics to display the configured preview limit.
  • Documentation
    • Documented inline output limits, configuration options, and widget behavior.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds configurable inline output limits, a preview/metadata utility, and bounded response shaping for shell, read, grep, glob, and ls tools. CLI configuration handling, documentation, tests, and widget-aware metadata are updated accordingly.

Changes

Inline output previews

Layer / File(s) Summary
Preview engine and validation
src/tool-output.ts, src/tool-output.test.ts, package.json
Adds code-point-aware head/tail previews, receipts, output metadata, validation, and test coverage for truncation, Unicode, payload size, and complete output.
Inline output configuration
src/config.ts, src/user-config.ts, src/cli.ts, src/config.test.ts, docs/configuration.md
Adds the configurable inline character limit with environment and file fallbacks, initialization persistence, CLI reporting, validation tests, and documentation.
Bounded tool responses
src/server.ts
Applies bounded content, structured metadata, bounded summaries, error handling, process output formatting, and widget-conditional cards across text-heavy tools.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: waishnav

Poem

I bounded the output, said the rabbit with cheer,
Keeping the head and the tail nice and clear.
With receipts and counts tucked neatly inside,
Big walls of text now have less room to hide.
A hop through the tools, and the payloads run light!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: bounding and de-duplicating text-heavy tool output.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
src/tool-output.ts (1)

64-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Untested fallback branch.

The maxCharacters <= markerCharacters head-only fallback (reachable with a small but valid configured limit) isn't exercised in tool-output.test.ts. Worth a regression test to lock in this edge case, since a small user-configured inlineOutputCharacters would hit it silently.

🤖 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 `@src/tool-output.ts` around lines 64 - 74, Add a regression test in
tool-output.test.ts covering the maxCharacters <= markerCharacters fallback in
the tool-output formatting flow. Configure a small valid inlineOutputCharacters
limit that triggers the head-only path, then assert the returned text, character
counts, omittedCharacters, and truncated flag match the expected behavior.
src/server.ts (1)

1028-1058: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Repeated bounded-response assembly across 5 tool handlers.

read (here), grep (1400-1429), glob (1470-1499), ls (1540-1565), and shell (1626-1657) each hand-roll the same sequence: bound error content via boundResponseContent, then on success build bounded, merge boundedSummary into a tool-specific summary, call toolResultMeta, and set structuredContent via boundedStructuredContent. processToolResponse (lines 581-609) already shows the pattern of extracting this into one shared helper for exec_command/write_stdin — applying the same treatment here would remove ~5x near-duplicated blocks and centralize future changes to the bounding contract.

A shared helper could take (config, kind, tool, workspaceId, path, response, extraSummaryFields) and return the final { content, _meta, structuredContent } shape, with each handler only supplying its tool-specific summary fields (pattern/scope, offset/limited, command/workingDirectory, etc.).

🤖 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 `@src/server.ts` around lines 1028 - 1058, Extract the repeated
bounded-response assembly from the read, grep, glob, ls, and shell handlers into
a shared helper, following the existing processToolResponse pattern. Have the
helper accept the config, tool kind/name, workspace and path context, response,
and tool-specific summary fields, then centralize boundResponseContent,
boundedSummary, toolResultMeta, and boundedStructuredContent while preserving
each handler’s existing metadata.
🤖 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 `@src/tool-output.ts`:
- Around line 15-93: Optimize createOutputPreview by avoiding repeated
full-string code-point materialization: first use value.length as a safe fast
path when the string is within maxCharacters, otherwise create one reusable
code-point array and use it for the original character count and head/tail
slicing. Update takeHead and takeTail or replace their use within
createOutputPreview so they consume the shared array, while preserving
lineCount, truncation boundaries, and reported counts.

---

Nitpick comments:
In `@src/server.ts`:
- Around line 1028-1058: Extract the repeated bounded-response assembly from the
read, grep, glob, ls, and shell handlers into a shared helper, following the
existing processToolResponse pattern. Have the helper accept the config, tool
kind/name, workspace and path context, response, and tool-specific summary
fields, then centralize boundResponseContent, boundedSummary, toolResultMeta,
and boundedStructuredContent while preserving each handler’s existing metadata.

In `@src/tool-output.ts`:
- Around line 64-74: Add a regression test in tool-output.test.ts covering the
maxCharacters <= markerCharacters fallback in the tool-output formatting flow.
Configure a small valid inlineOutputCharacters limit that triggers the head-only
path, then assert the returned text, character counts, omittedCharacters, and
truncated flag match the expected behavior.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 84b23e28-2318-4415-8b94-1ffa2549c6e9

📥 Commits

Reviewing files that changed from the base of the PR and between 80423b5 and 9de976a.

📒 Files selected for processing (9)
  • docs/configuration.md
  • package.json
  • src/cli.ts
  • src/config.test.ts
  • src/config.ts
  • src/server.ts
  • src/tool-output.test.ts
  • src/tool-output.ts
  • src/user-config.ts

Comment thread src/tool-output.ts
Comment on lines +15 to +93
function codePoints(value: string): string[] {
return Array.from(value);
}

function characterLength(value: string): number {
return codePoints(value).length;
}

function lineCount(value: string): number {
if (value.length === 0) return 0;
return value.endsWith("\n")
? value.slice(0, -1).split("\n").length
: value.split("\n").length;
}

function takeHead(value: string, count: number): string {
if (count <= 0) return "";
return codePoints(value).slice(0, count).join("");
}

function takeTail(value: string, count: number): string {
if (count <= 0) return "";
const characters = codePoints(value);
return characters.slice(Math.max(0, characters.length - count)).join("");
}

export function createOutputPreview(
value: string,
maxCharacters = DEFAULT_INLINE_OUTPUT_CHARACTERS,
): OutputPreview {
if (!Number.isInteger(maxCharacters) || maxCharacters < 1) {
throw new Error("Inline output limit must be a positive integer.");
}

const originalCharacters = characterLength(value);
const originalLines = lineCount(value);

if (originalCharacters <= maxCharacters) {
return {
text: value,
originalCharacters,
originalLines,
inlineCharacters: originalCharacters,
omittedCharacters: 0,
truncated: false,
};
}

const markerCharacters = characterLength(TRUNCATION_MARKER);
if (maxCharacters <= markerCharacters) {
const text = takeHead(value, maxCharacters);
return {
text,
originalCharacters,
originalLines,
inlineCharacters: characterLength(text),
omittedCharacters: originalCharacters - characterLength(text),
truncated: true,
};
}

const available = maxCharacters - markerCharacters;
const headCharacters = Math.ceil(available * 0.65);
const tailCharacters = available - headCharacters;
const text =
takeHead(value, headCharacters) +
TRUNCATION_MARKER +
takeTail(value, tailCharacters);
const inlineCharacters = characterLength(text);

return {
text,
originalCharacters,
originalLines,
inlineCharacters,
omittedCharacters: originalCharacters - headCharacters - tailCharacters,
truncated: true,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Multiple full-string passes on the truncation hot path.

For large value (the exact case this feature targets — big file reads, verbose grep/ls/shell output), createOutputPreview performs several independent O(n) full-string materializations: characterLength(value) (line 49), lineCount(value)'s .split("\n") (line 50), then takeHead(value, ...) and takeTail(value, ...) each call codePoints(value) again (lines 65/80-82). Each of these builds a brand-new array holding every code point of the entire original string, even though only a ~12,000-character preview is ultimately needed. This scales poorly with input size and works against the PR's own goal of bounding cost for text-heavy tools.

A low-risk improvement: compute the code-point array once and reuse it for length, head, and tail instead of re-deriving it 3+ times; additionally, a fast path using value.length (UTF‑16 length is always ≥ code-point length) can skip the expensive path entirely whenever the string is already within bounds.

♻️ Suggested optimization sketch
 function createOutputPreview(
   value: string,
   maxCharacters = DEFAULT_INLINE_OUTPUT_CHARACTERS,
 ): OutputPreview {
   if (!Number.isInteger(maxCharacters) || maxCharacters < 1) {
     throw new Error("Inline output limit must be a positive integer.");
   }

-  const originalCharacters = characterLength(value);
-  const originalLines = lineCount(value);
-
-  if (originalCharacters <= maxCharacters) {
+  const originalLines = lineCount(value);
+
+  // Fast path: UTF-16 length is always >= code-point length, so if it already
+  // fits, we know the code-point count fits too without materializing the array.
+  if (value.length <= maxCharacters) {
+    const originalCharacters = characterLength(value);
+    if (originalCharacters <= maxCharacters) {
       return {
         text: value,
         originalCharacters,
         originalLines,
         inlineCharacters: originalCharacters,
         omittedCharacters: 0,
         truncated: false,
       };
+    }
   }

+  const points = codePoints(value); // single materialization, reused below
+  const originalCharacters = points.length;
   const markerCharacters = characterLength(TRUNCATION_MARKER);
   ...
-  const text = takeHead(value, headCharacters) + TRUNCATION_MARKER + takeTail(value, tailCharacters);
+  const text =
+    points.slice(0, headCharacters).join("") +
+    TRUNCATION_MARKER +
+    points.slice(Math.max(0, points.length - tailCharacters)).join("");
🤖 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 `@src/tool-output.ts` around lines 15 - 93, Optimize createOutputPreview by
avoiding repeated full-string code-point materialization: first use value.length
as a safe fast path when the string is within maxCharacters, otherwise create
one reusable code-point array and use it for the original character count and
head/tail slicing. Update takeHead and takeTail or replace their use within
createOutputPreview so they consume the shared array, while preserving
lineCount, truncation boundaries, and reported counts.

@Reneromero08
Reneromero08 marked this pull request as draft July 19, 2026 09:04
@Reneromero08

Copy link
Copy Markdown
Author

I apologize, my agent got a little trigger happy and sent out a PR without my approval, I told him to fork it not send you a PR.

I will continue to work on this but I wasn't planning on sending out a PR until I reached a solid implementation of the issue I was running into.

You can ignore or review, up to you.

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