Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b89fe0e

Browse files
committed
feat: add read_command_output tool for retrieving truncated command output
Implements a new tool that allows the LLM to retrieve full command output when execute_command produces output exceeding the preview threshold. Key components: - ReadCommandOutputTool: Reads persisted output with search/pagination - OutputInterceptor: Intercepts and persists large command outputs to disk - Terminal settings UI: Configuration for output interception behavior - Type definitions for output interception settings The tool supports: - Reading full output beyond the truncated preview - Search/filtering with regex patterns (like grep) - Pagination through large outputs using offset/limit Includes comprehensive tests for ReadCommandOutputTool and OutputInterceptor.
1 parent c7910a9 commit b89fe0e

22 files changed

Lines changed: 2206 additions & 73 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,42 @@ export const DEFAULT_WRITE_DELAY_MS = 1000
2929
*/
3030
export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
3131

32+
/**
33+
* Terminal output preview size options for persisted command output.
34+
*
35+
* Controls how much command output is kept in memory as a "preview" before
36+
* the LLM decides to retrieve more via `read_command_output`. Larger previews
37+
* mean more immediate context but consume more of the context window.
38+
*
39+
* - `small`: 2KB preview - Best for long-running commands with verbose output
40+
* - `medium`: 4KB preview - Balanced default for most use cases
41+
* - `large`: 8KB preview - Best when commands produce critical info early
42+
*
43+
* @see OutputInterceptor - Uses this setting to determine when to spill to disk
44+
* @see PersistedCommandOutput - Contains the resulting preview and artifact reference
45+
*/
46+
export type TerminalOutputPreviewSize = "small" | "medium" | "large"
47+
48+
/**
49+
* Byte limits for each terminal output preview size.
50+
*
51+
* Maps preview size names to their corresponding byte thresholds.
52+
* When command output exceeds these thresholds, the excess is persisted
53+
* to disk and made available via the `read_command_output` tool.
54+
*/
55+
export const TERMINAL_PREVIEW_BYTES: Record<TerminalOutputPreviewSize, number> = {
56+
small: 2048, // 2KB
57+
medium: 4096, // 4KB
58+
large: 8192, // 8KB
59+
}
60+
61+
/**
62+
* Default terminal output preview size.
63+
* The "medium" (4KB) setting provides a good balance between immediate
64+
* visibility and context window conservation for most use cases.
65+
*/
66+
export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium"
67+
3268
/**
3369
* Minimum checkpoint timeout in seconds.
3470
*/
@@ -149,6 +185,7 @@ export const globalSettingsSchema = z.object({
149185

150186
terminalOutputLineLimit: z.number().optional(),
151187
terminalOutputCharacterLimit: z.number().optional(),
188+
terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(),
152189
terminalShellIntegrationTimeout: z.number().optional(),
153190
terminalShellIntegrationDisabled: z.boolean().optional(),
154191
terminalCommandDelay: z.number().optional(),

packages/types/src/terminal.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,69 @@ export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
3232
])
3333

3434
export type CommandExecutionStatus = z.infer<typeof commandExecutionStatusSchema>
35+
36+
/**
37+
* PersistedCommandOutput
38+
*
39+
* Represents the result of a terminal command execution that may have been
40+
* truncated and persisted to disk.
41+
*
42+
* When command output exceeds the configured preview threshold, the full
43+
* output is saved to a disk artifact file. The LLM receives this structure
44+
* which contains:
45+
* - A preview of the output (for immediate display in context)
46+
* - Metadata about the full output (size, truncation status)
47+
* - A path to the artifact file for later retrieval via `read_command_output`
48+
*
49+
* ## Usage in execute_command Response
50+
*
51+
* The response format depends on whether truncation occurred:
52+
*
53+
* **Not truncated** (output fits in preview):
54+
* ```json
55+
* {
56+
* "preview": "full output here...",
57+
* "totalBytes": 1234,
58+
* "artifactPath": null,
59+
* "truncated": false
60+
* }
61+
* ```
62+
*
63+
* **Truncated** (output exceeded threshold):
64+
* ```json
65+
* {
66+
* "preview": "first 4KB of output...",
67+
* "totalBytes": 1048576,
68+
* "artifactPath": "/path/to/tasks/123/command-output/cmd-1706119234567.txt",
69+
* "truncated": true
70+
* }
71+
* ```
72+
*
73+
* @see OutputInterceptor - Creates these results during command execution
74+
* @see ReadCommandOutputTool - Retrieves full content from artifact files
75+
*/
76+
export interface PersistedCommandOutput {
77+
/**
78+
* Preview of the command output, truncated to the preview threshold.
79+
* Always contains the beginning of the output, even if truncated.
80+
*/
81+
preview: string
82+
83+
/**
84+
* Total size of the command output in bytes.
85+
* Useful for determining if additional reads are needed.
86+
*/
87+
totalBytes: number
88+
89+
/**
90+
* Absolute path to the artifact file containing full output.
91+
* `null` if output wasn't truncated (no artifact was created).
92+
*/
93+
artifactPath: string | null
94+
95+
/**
96+
* Whether the output was truncated (exceeded preview threshold).
97+
* When `true`, use `read_command_output` to retrieve full content.
98+
*/
99+
truncated: boolean
100+
}

packages/types/src/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type ToolGroup = z.infer<typeof toolGroupsSchema>
1717
export const toolNames = [
1818
"execute_command",
1919
"read_file",
20+
"read_command_output",
2021
"write_to_file",
2122
"apply_diff",
2223
"search_and_replace",

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export type ExtensionState = Pick<
302302
| "maxConcurrentFileReads"
303303
| "terminalOutputLineLimit"
304304
| "terminalOutputCharacterLimit"
305+
| "terminalOutputPreviewSize"
305306
| "terminalShellIntegrationTimeout"
306307
| "terminalShellIntegrationDisabled"
307308
| "terminalCommandDelay"

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Task } from "../task/Task"
1717
import { fetchInstructionsTool } from "../tools/FetchInstructionsTool"
1818
import { listFilesTool } from "../tools/ListFilesTool"
1919
import { readFileTool } from "../tools/ReadFileTool"
20+
import { readCommandOutputTool } from "../tools/ReadCommandOutputTool"
2021
import { writeToFileTool } from "../tools/WriteToFileTool"
2122
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
2223
import { searchReplaceTool } from "../tools/SearchReplaceTool"
@@ -395,8 +396,10 @@ export async function presentAssistantMessage(cline: Task) {
395396
return `[${block.name}]`
396397
case "switch_mode":
397398
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
398-
case "codebase_search": // Add case for the new tool
399+
case "codebase_search":
399400
return `[${block.name} for '${block.params.query}']`
401+
case "read_command_output":
402+
return `[${block.name} for '${block.params.artifact_id}']`
400403
case "update_todo_list":
401404
return `[${block.name}]`
402405
case "new_task": {
@@ -833,6 +836,13 @@ export async function presentAssistantMessage(cline: Task) {
833836
pushToolResult,
834837
})
835838
break
839+
case "read_command_output":
840+
await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, {
841+
askApproval,
842+
handleError,
843+
pushToolResult,
844+
})
845+
break
836846
case "use_mcp_tool":
837847
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
838848
askApproval,
@@ -1074,6 +1084,7 @@ function containsXmlToolMarkup(text: string): boolean {
10741084
"generate_image",
10751085
"list_files",
10761086
"new_task",
1087+
"read_command_output",
10771088
"read_file",
10781089
"search_and_replace",
10791090
"search_files",

src/core/message-manager/index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import * as path from "path"
12
import { Task } from "../task/Task"
23
import { ClineMessage } from "@roo-code/types"
34
import { ApiMessage } from "../task-persistence/apiMessages"
45
import { cleanupAfterTruncation } from "../condense"
6+
import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor"
7+
import { getTaskDirectoryPath } from "../../utils/storage"
58

69
export interface RewindOptions {
710
/** Whether to include the target message in deletion (edit=true, delete=false) */
@@ -207,6 +210,32 @@ export class MessageManager {
207210
apiHistory = cleanupAfterTruncation(apiHistory)
208211
}
209212

213+
// Step 6: Cleanup orphaned command output artifacts
214+
// Collect timestamps from remaining messages to identify valid artifact IDs
215+
// Artifacts whose IDs don't match any remaining message timestamp will be removed
216+
if (!skipCleanup) {
217+
const validIds = new Set<string>()
218+
219+
// Collect timestamps from remaining clineMessages
220+
for (const msg of this.task.clineMessages) {
221+
if (msg.ts) {
222+
validIds.add(String(msg.ts))
223+
}
224+
}
225+
226+
// Collect timestamps from remaining apiHistory
227+
for (const msg of apiHistory) {
228+
if (msg.ts) {
229+
validIds.add(String(msg.ts))
230+
}
231+
}
232+
233+
// Cleanup artifacts asynchronously (fire-and-forget with error handling)
234+
this.cleanupOrphanedArtifacts(validIds).catch((error) => {
235+
console.error("[MessageManager] Error cleaning up orphaned command output artifacts:", error)
236+
})
237+
}
238+
210239
// Only write if the history actually changed
211240
const historyChanged =
212241
apiHistory.length !== originalHistory.length || apiHistory.some((msg, i) => msg !== originalHistory[i])
@@ -215,4 +244,28 @@ export class MessageManager {
215244
await this.task.overwriteApiConversationHistory(apiHistory)
216245
}
217246
}
247+
248+
/**
249+
* Cleanup orphaned command output artifacts.
250+
* Removes artifact files whose execution IDs don't match any remaining message timestamps.
251+
*/
252+
private async cleanupOrphanedArtifacts(validIds: Set<string>): Promise<void> {
253+
try {
254+
// Access globalStoragePath and taskId through the task reference
255+
const task = this.task as any // Access private member
256+
const globalStoragePath = task.globalStoragePath
257+
const taskId = task.taskId
258+
259+
if (!globalStoragePath || !taskId) {
260+
return
261+
}
262+
263+
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
264+
const outputDir = path.join(taskDir, "command-output")
265+
await OutputInterceptor.cleanupByIds(outputDir, validIds)
266+
} catch (error) {
267+
// Silently fail - cleanup is best-effort
268+
console.debug("[MessageManager] Artifact cleanup skipped:", error)
269+
}
270+
}
218271
}

src/core/prompts/tools/native-tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import fetchInstructions from "./fetch_instructions"
1111
import generateImage from "./generate_image"
1212
import listFiles from "./list_files"
1313
import newTask from "./new_task"
14+
import readCommandOutput from "./read_command_output"
1415
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
1516
import runSlashCommand from "./run_slash_command"
1617
import searchAndReplace from "./search_and_replace"
@@ -65,6 +66,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
6566
generateImage,
6667
listFiles,
6768
newTask,
69+
readCommandOutput,
6870
createReadFileTool(readFileOptions),
6971
runSlashCommand,
7072
searchAndReplace,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type OpenAI from "openai"
2+
3+
/**
4+
* Native tool definition for read_command_output.
5+
*
6+
* This tool allows the LLM to retrieve full command output that was truncated
7+
* during execute_command. When command output exceeds the preview threshold,
8+
* the full output is persisted to disk and an artifact_id is provided. The
9+
* LLM can then use this tool to read the full content or search within it.
10+
*/
11+
12+
const READ_COMMAND_OUTPUT_DESCRIPTION = `Retrieve the full output from a command that was truncated in execute_command. Use this tool when:
13+
1. The execute_command result shows "[OUTPUT TRUNCATED - Full output saved to artifact: cmd-XXXX.txt]"
14+
2. You need to see more of the command output beyond the preview
15+
3. You want to search for specific content in large command output
16+
17+
The tool supports two modes:
18+
- **Read mode**: Read output starting from a byte offset with optional limit
19+
- **Search mode**: Filter lines matching a regex or literal pattern (like grep)
20+
21+
Parameters:
22+
- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt")
23+
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive.
24+
- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination.
25+
- limit: (optional) Maximum bytes to return. Default: 32KB.
26+
27+
Example: Reading truncated command output
28+
{ "artifact_id": "cmd-1706119234567.txt" }
29+
30+
Example: Reading with pagination (after first 32KB)
31+
{ "artifact_id": "cmd-1706119234567.txt", "offset": 32768 }
32+
33+
Example: Searching for errors in build output
34+
{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" }
35+
36+
Example: Finding specific test failures
37+
{ "artifact_id": "cmd-1706119234567.txt", "search": "FAIL" }`
38+
39+
const ARTIFACT_ID_DESCRIPTION = `The artifact filename from the truncated command output (e.g., "cmd-1706119234567.txt")`
40+
41+
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep)`
42+
43+
const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)`
44+
45+
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)`
46+
47+
export default {
48+
type: "function",
49+
function: {
50+
name: "read_command_output",
51+
description: READ_COMMAND_OUTPUT_DESCRIPTION,
52+
strict: true,
53+
parameters: {
54+
type: "object",
55+
properties: {
56+
artifact_id: {
57+
type: "string",
58+
description: ARTIFACT_ID_DESCRIPTION,
59+
},
60+
search: {
61+
type: ["string", "null"],
62+
description: SEARCH_DESCRIPTION,
63+
},
64+
offset: {
65+
type: ["number", "null"],
66+
description: OFFSET_DESCRIPTION,
67+
},
68+
limit: {
69+
type: ["number", "null"],
70+
description: LIMIT_DESCRIPTION,
71+
},
72+
},
73+
required: ["artifact_id", "search", "offset", "limit"],
74+
additionalProperties: false,
75+
},
76+
},
77+
} satisfies OpenAI.Chat.ChatCompletionTool

0 commit comments

Comments
 (0)