feat: Add read_lints tool

Integrate the read_lints tool into the assistant message handling and native tool call parser. Update task management to track edited file paths for read_lints functionality. Enhance tool parameter definitions and ensure proper registration in the native tools index.
This commit is contained in:
Ruslan Andreev 2026-03-02 17:45:36 +03:00
parent 7118a14188
commit ac2de60560
10 changed files with 442 additions and 1 deletions

View file

@ -34,6 +34,7 @@ export const toolNames = [
"apply_patch",
"search_files",
"list_files",
"read_lints",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",

View file

@ -89,6 +89,23 @@ export class NativeToolCallParser {
return undefined
}
private static coerceStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string")
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed)
? (parsed as unknown[]).filter((item): item is string => typeof item === "string")
: []
} catch {
return []
}
}
return []
}
/**
* Process a raw tool call chunk from the API stream.
* Handles tracking, buffering, and emits start/delta/end events.
@ -627,6 +644,14 @@ export class NativeToolCallParser {
}
break
case "read_lints":
if (partialArgs.paths !== undefined) {
nativeArgs = {
paths: this.coerceStringArray(partialArgs.paths),
}
}
break
case "new_task":
if (partialArgs.mode !== undefined || partialArgs.message !== undefined) {
nativeArgs = {
@ -976,6 +1001,12 @@ export class NativeToolCallParser {
}
break
case "read_lints":
nativeArgs = {
paths: this.coerceStringArray(args.paths),
} as NativeArgsFor<TName>
break
case "new_task":
if (args.mode !== undefined && args.message !== undefined) {
nativeArgs = {

View file

@ -37,6 +37,7 @@ import { generateImageTool } from "../tools/GenerateImageTool"
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
import { isValidToolName, validateToolUse } from "../tools/validateToolUse"
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
import { readLintsTool } from "../tools/ReadLintsTool"
import { formatResponse } from "../prompts/responses"
import { sanitizeToolUseId } from "../../utils/tool-id"
@ -355,6 +356,8 @@ export async function presentAssistantMessage(cline: Task) {
return `[${block.name}]`
case "list_files":
return `[${block.name} for '${block.params.path}']`
case "read_lints":
return block.params.paths ? `[${block.name} for paths]` : `[${block.name}]`
case "use_mcp_tool":
return `[${block.name} for '${block.params.server_name}']`
case "access_mcp_resource":
@ -747,6 +750,13 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult,
})
break
case "read_lints":
await readLintsTool.handle(cline, block as ToolUse<"read_lints">, {
askApproval,
handleError,
pushToolResult,
})
break
case "codebase_search":
await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, {
askApproval,

View file

@ -10,6 +10,7 @@ import executeCommand from "./execute_command"
import generateImage from "./generate_image"
import listFiles from "./list_files"
import newTask from "./new_task"
import readLints from "./read_lints"
import readCommandOutput from "./read_command_output"
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
import runSlashCommand from "./run_slash_command"
@ -57,6 +58,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
generateImage,
listFiles,
newTask,
readLints,
readCommandOutput,
createReadFileTool(readFileOptions),
runSlashCommand,

View file

@ -0,0 +1,38 @@
import type OpenAI from "openai"
const READ_LINTS_DESCRIPTION = `Read linter errors and warnings from the workspace. Use this after editing files to check for problems. If no paths are provided, returns diagnostics only for files that have been edited in this task. If paths are provided, returns diagnostics for those files or directories (relative to the current workspace directory).
Parameters:
- paths: (optional) Array of file or directory paths to get diagnostics for. If omitted, returns diagnostics for files edited in this task (or a message if none edited yet).
Example: Get lints for files edited in this task
{ }
Example: Get lints for a specific file
{ "paths": ["src/foo.ts"] }
Example: Get lints for a directory
{ "paths": ["src"] }`
const PATHS_PARAMETER_DESCRIPTION = `Optional array of file or directory paths (relative to workspace) to get diagnostics for. If omitted, returns diagnostics only for files edited in this task.`
export default {
type: "function",
function: {
name: "read_lints",
description: READ_LINTS_DESCRIPTION,
strict: true,
parameters: {
type: "object",
properties: {
paths: {
type: "array",
items: { type: "string" },
description: PATHS_PARAMETER_DESCRIPTION,
},
},
required: [],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -304,6 +304,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
diffViewProvider: DiffViewProvider
diffStrategy?: DiffStrategy
didEditFile: boolean = false
/** Relative paths (POSIX) of files written in this task, for read_lints "edited only" scope. */
editedFilePaths: Set<string> = new Set()
// LLM Messages & Chat Messages
apiConversationHistory: ApiMessage[] = []
@ -1876,6 +1878,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
}
/**
* Record that a file was edited in this task (for read_lints "edited only" scope).
* Call this after successfully saving a file via DiffViewProvider.
*/
recordEditedFile(relPath: string): void {
const normalized = path.relative(this.cwd, path.resolve(this.cwd, relPath)).toPosix()
this.editedFilePaths.add(normalized)
}
// Lifecycle
// Start / Resume / Abort / Dispose

View file

@ -0,0 +1,126 @@
import * as path from "path"
import * as vscode from "vscode"
import { Task } from "../task/Task"
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import type { ToolUse } from "../../shared/tools"
import { BaseTool, ToolCallbacks } from "./BaseTool"
const NO_EDITS_MESSAGE =
"No files have been edited in this task yet. Edit a file, then use read_lints to see errors and warnings."
const NO_PROBLEMS_MESSAGE = "No errors or warnings detected."
interface ReadLintsParams {
paths?: string[]
}
/**
* Normalize a path to POSIX relative form for comparison with editedFilePaths.
*/
function toRelativePosix(cwd: string, absolutePath: string): string {
return path.relative(cwd, absolutePath).toPosix()
}
/**
* Check if a file URI is under a directory (both relative to cwd).
*/
function isUnderDir(fileRelPosix: string, dirRelPosix: string): boolean {
if (dirRelPosix === "." || dirRelPosix === "") {
return true
}
const norm = dirRelPosix.endsWith("/") ? dirRelPosix : dirRelPosix + "/"
return fileRelPosix === dirRelPosix || fileRelPosix.startsWith(norm)
}
export class ReadLintsTool extends BaseTool<"read_lints"> {
readonly name = "read_lints" as const
async execute(params: ReadLintsParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { pushToolResult, handleError } = callbacks
const { paths } = params
const cwd = task.cwd
try {
const state = await task.providerRef.deref()?.getState()
const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true
const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50
if (!includeDiagnosticMessages) {
pushToolResult(NO_PROBLEMS_MESSAGE)
return
}
let diagnosticsTuples: [vscode.Uri, vscode.Diagnostic[]][] = []
if (paths === undefined || paths.length === 0) {
// No paths: return diagnostics only for files edited in this task
if (task.editedFilePaths.size === 0) {
pushToolResult(NO_EDITS_MESSAGE)
return
}
const allDiagnostics = vscode.languages.getDiagnostics()
const editedSet = task.editedFilePaths
for (const [uri, diags] of allDiagnostics) {
const relPosix = toRelativePosix(cwd, uri.fsPath)
if (editedSet.has(relPosix)) {
diagnosticsTuples.push([uri, diags])
}
}
} else {
// Paths provided: return diagnostics for those files/directories
const allDiagnostics = vscode.languages.getDiagnostics()
const dirPaths: string[] = []
const fileUris: vscode.Uri[] = []
for (const relPath of paths) {
if (!relPath || typeof relPath !== "string") continue
const absolutePath = path.resolve(cwd, relPath)
const uri = vscode.Uri.file(absolutePath)
try {
const stat = await vscode.workspace.fs.stat(uri)
if (stat.type === vscode.FileType.Directory) {
dirPaths.push(toRelativePosix(cwd, absolutePath))
} else {
fileUris.push(uri)
}
} catch {
// Path may not exist; treat as file and try getDiagnostics(uri)
fileUris.push(uri)
}
}
const seenUri = new Set<string>()
for (const uri of fileUris) {
const diags = vscode.languages.getDiagnostics(uri)
if (diags.length > 0) {
diagnosticsTuples.push([uri, diags])
seenUri.add(uri.toString())
}
}
for (const [uri, diags] of allDiagnostics) {
if (diags.length === 0 || seenUri.has(uri.toString())) continue
const fileRelPosix = toRelativePosix(cwd, uri.fsPath)
const included = dirPaths.some((dirRelPosix) => isUnderDir(fileRelPosix, dirRelPosix))
if (included) {
diagnosticsTuples.push([uri, diags])
}
}
}
const result = await diagnosticsToProblemsString(
diagnosticsTuples,
[vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning],
cwd,
true,
maxDiagnosticMessages,
)
pushToolResult(result.trim() ? result.trim() : NO_PROBLEMS_MESSAGE)
} catch (error) {
await handleError("reading lints", error instanceof Error ? error : new Error(String(error)))
}
}
}
export const readLintsTool = new ReadLintsTool()

View file

@ -0,0 +1,209 @@
import * as path from "path"
import * as vscode from "vscode"
import { readLintsTool } from "../ReadLintsTool"
const NO_EDITS_MESSAGE =
"No files have been edited in this task yet. Edit a file, then use read_lints to see errors and warnings."
const NO_PROBLEMS_MESSAGE = "No errors or warnings detected."
vi.mock("vscode", () => ({
Uri: {
file: (p: string) => ({
fsPath: p,
toString: () => p,
}),
},
Diagnostic: class {
constructor(
public range: { start: { line: number }; end: { line: number } },
public message: string,
public severity: number,
) {}
},
Range: class {
start: { line: number; character: number }
end: { line: number; character: number }
constructor(startLine: number, startChar: number, endLine: number, endChar: number) {
this.start = { line: startLine, character: startChar }
this.end = { line: endLine, character: endChar }
}
},
DiagnosticSeverity: {
Error: 0,
Warning: 1,
Information: 2,
Hint: 3,
},
FileType: {
Unknown: 0,
File: 1,
Directory: 2,
SymbolicLink: 64,
},
languages: {
getDiagnostics: vi.fn(),
},
workspace: {
fs: {
stat: vi.fn(),
},
openTextDocument: vi.fn(),
},
}))
vi.mock("../../../integrations/diagnostics", () => ({
diagnosticsToProblemsString: vi.fn(
async (diagnostics: [vscode.Uri, vscode.Diagnostic[]][], _severities: unknown, _cwd: string) => {
if (diagnostics.length === 0) return ""
return diagnostics
.map(([uri, diags]) => `${path.basename(uri.fsPath)}\n${diags.map((d) => ` ${d.message}`).join("\n")}`)
.join("\n\n")
},
),
}))
describe("ReadLintsTool", () => {
const cwd = path.resolve("/project")
let mockTask: any
let mockPushToolResult: ReturnType<typeof vi.fn>
let mockHandleError: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockPushToolResult = vi.fn()
mockHandleError = vi.fn()
mockTask = {
cwd,
editedFilePaths: new Set<string>(),
providerRef: {
deref: vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
}),
}),
},
}
vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([])
vi.mocked(vscode.workspace.fs.stat).mockResolvedValue({
type: vscode.FileType.File,
ctime: 0,
mtime: 0,
size: 0,
})
})
it("returns NO_EDITS_MESSAGE when no paths and no edited files", async () => {
mockTask.editedFilePaths = new Set<string>()
await readLintsTool.execute({}, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(mockPushToolResult).toHaveBeenCalledWith(NO_EDITS_MESSAGE)
expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled()
})
it("filters diagnostics to edited files when no paths provided", async () => {
const editedRel = "src/foo.ts"
mockTask.editedFilePaths = new Set([editedRel])
const fileUri = vscode.Uri.file(path.join(cwd, editedRel))
const diag = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 5), "Test error", vscode.DiagnosticSeverity.Error)
vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([[fileUri, [diag]]])
await readLintsTool.execute({}, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(vscode.languages.getDiagnostics).toHaveBeenCalledWith()
expect(mockPushToolResult).toHaveBeenCalled()
const result = mockPushToolResult.mock.calls[0][0]
expect(result).toContain("foo.ts")
expect(result).toContain("Test error")
})
it("returns NO_PROBLEMS_MESSAGE when includeDiagnosticMessages is false", async () => {
mockTask.editedFilePaths = new Set(["src/foo.ts"])
mockTask.providerRef.deref = vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({
includeDiagnosticMessages: false,
maxDiagnosticMessages: 50,
}),
})
await readLintsTool.execute({}, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(mockPushToolResult).toHaveBeenCalledWith(NO_PROBLEMS_MESSAGE)
})
it("returns diagnostics for requested file path", async () => {
const fileUri = vscode.Uri.file(path.join(cwd, "src/bar.ts"))
const diag = new vscode.Diagnostic(
new vscode.Range(1, 0, 1, 10),
"Bar warning",
vscode.DiagnosticSeverity.Warning,
)
vi.mocked(vscode.languages.getDiagnostics).mockImplementation((uri?: vscode.Uri): any => {
if (uri && uri.fsPath === fileUri.fsPath) return [diag]
return []
})
await readLintsTool.execute({ paths: ["src/bar.ts"] }, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(mockPushToolResult).toHaveBeenCalled()
const result = mockPushToolResult.mock.calls[0][0]
expect(result).toContain("Bar warning")
})
it("returns NO_PROBLEMS_MESSAGE when diagnostics are empty", async () => {
mockTask.editedFilePaths = new Set(["src/empty.ts"])
vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([])
await readLintsTool.execute({}, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(mockPushToolResult).toHaveBeenCalledWith(NO_PROBLEMS_MESSAGE)
})
it("only includes Error and Warning severities in output", async () => {
const { diagnosticsToProblemsString } = await import("../../../integrations/diagnostics")
vi.mocked(diagnosticsToProblemsString).mockResolvedValue("file.ts\n Error message\n Warning message")
mockTask.editedFilePaths = new Set(["src/file.ts"])
const fileUri = vscode.Uri.file(path.join(cwd, "src/file.ts"))
const diagnostics = [
new vscode.Diagnostic(new vscode.Range(0, 0, 0, 5), "Error message", vscode.DiagnosticSeverity.Error),
new vscode.Diagnostic(new vscode.Range(1, 0, 1, 5), "Warning message", vscode.DiagnosticSeverity.Warning),
new vscode.Diagnostic(new vscode.Range(2, 0, 2, 5), "Info message", vscode.DiagnosticSeverity.Information),
]
vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([[fileUri, diagnostics]])
await readLintsTool.execute({}, mockTask, {
askApproval: vi.fn().mockResolvedValue(true),
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
})
expect(diagnosticsToProblemsString).toHaveBeenCalledWith(
expect.any(Array),
[vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning],
cwd,
true,
50,
)
})
})

View file

@ -213,6 +213,11 @@ export class DiffViewProvider {
await updatedDocument.save()
}
const task = this.taskRef.deref()
if (task?.recordEditedFile && this.relPath) {
task.recordEditedFile(this.relPath)
}
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true })
await this.closeAllDiffViews()
@ -718,6 +723,11 @@ export class DiffViewProvider {
this.relPath = relPath
this.newContent = content
const taskForRecord = this.taskRef.deref()
if (taskForRecord?.recordEditedFile) {
taskForRecord.recordEditedFile(relPath)
}
return {
newProblemsMessage,
userEdits: undefined,

View file

@ -26,6 +26,7 @@ export interface TextContent {
export const toolParamNames = [
"command",
"path",
"paths",
"content",
"regex",
"file_pattern",
@ -102,6 +103,7 @@ export type NativeToolArgs = {
edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number }
apply_patch: { patch: string }
list_files: { path: string; recursive?: boolean }
read_lints: { paths?: string[] }
new_task: { mode: string; message: string; todos?: string }
ask_followup_question: {
question: string
@ -278,6 +280,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
apply_patch: "apply patches using codex format",
search_files: "search files",
list_files: "list files",
read_lints: "read lints",
use_mcp_tool: "use mcp tools",
access_mcp_resource: "access mcp resources",
ask_followup_question: "ask questions",
@ -295,7 +298,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
// Define available tool groups.
export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
read: {
tools: ["read_file", "search_files", "list_files", "codebase_search"],
tools: ["read_file", "search_files", "list_files", "codebase_search", "read_lints"],
},
edit: {
tools: ["apply_diff", "write_to_file", "generate_image"],