Record tool usages in the Cline object, and persist them in the db for evals (#2729)

This commit is contained in:
Chris Estreich 2025-04-17 22:21:14 -07:00 • committed by GitHub
parent b5a77e34a4
commit 87af3b3424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 977 additions and 222 deletions

View file

@ -275,7 +275,12 @@ const runExercise = async ({ run, task, server }: { run: Run; task: Task; server
})
}
if (eventName === RooCodeEventName.TaskCompleted || eventName === RooCodeEventName.TaskAborted) {
if (eventName === RooCodeEventName.TaskCompleted && taskMetricsId) {
const toolUsage = payload[2]
await updateTaskMetrics(taskMetricsId, { toolUsage })
}
if (eventName === RooCodeEventName.TaskAborted || eventName === RooCodeEventName.TaskCompleted) {
taskFinishedAt = Date.now()
await updateTask(task.id, { finishedAt: new Date() })
}

View file

@ -8,7 +8,7 @@ import { Ellipsis, Rocket } from "lucide-react"
import type { Run, TaskMetrics } from "@evals/db"
import { deleteRun } from "@/lib/server/runs"
import { formatCurrency, formatDuration, formatTokens } from "@/lib"
import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters"
import {
Button,
Table,
@ -59,7 +59,8 @@ export function Home({ runs }: { runs: (Run & { taskMetrics: TaskMetrics | null
<TableHead>Passed</TableHead>
<TableHead>Failed</TableHead>
<TableHead>% Correct</TableHead>
<TableHead className="text-center">Tokens In / Out</TableHead>
<TableHead>Tokens In / Out</TableHead>
<TableHead>Diff Edits</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Duration</TableHead>
<TableHead />
@ -79,12 +80,21 @@ export function Home({ runs }: { runs: (Run & { taskMetrics: TaskMetrics | null
</TableCell>
<TableCell>
{taskMetrics && (
<div className="flex items-center justify-evenly">
<div className="flex items-center gap-1.5">
<div>{formatTokens(taskMetrics.tokensIn)}</div>/
<div>{formatTokens(taskMetrics.tokensOut)}</div>
</div>
)}
</TableCell>
<TableCell>
{taskMetrics?.toolUsage?.apply_diff && (
<div className="flex flex-row items-center gap-1.5">
<div>{taskMetrics.toolUsage.apply_diff.attempts}</div>
<div>/</div>
<div>{formatToolUsageSuccessRate(taskMetrics.toolUsage.apply_diff)}</div>
</div>
)}
</TableCell>
<TableCell>{taskMetrics && formatCurrency(taskMetrics.cost)}</TableCell>
<TableCell>{taskMetrics && formatDuration(taskMetrics.duration)}</TableCell>
<TableCell>

View file

@ -5,7 +5,7 @@ import { LoaderCircle } from "lucide-react"
import * as db from "@evals/db"
import { formatCurrency, formatDuration, formatTokens } from "@/lib"
import { formatCurrency, formatDuration, formatTokens } from "@/lib/formatters"
import { useRunStatus } from "@/hooks/use-run-status"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"

View file

@ -1,6 +0,0 @@
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export const formatCurrency = (amount: number) => formatter.format(amount)

View file

@ -1,22 +0,0 @@
export const formatDuration = (durationMs: number) => {
const seconds = Math.floor(durationMs / 1000)
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
const parts = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0) {
parts.push(`${minutes}m`)
}
if (remainingSeconds > 0 || parts.length === 0) {
parts.push(`${remainingSeconds}s`)
}
return parts.join(" ")
}

View file

@ -1,15 +0,0 @@
export const formatTokens = (tokens: number) => {
if (tokens < 1000) {
return tokens.toString()
}
if (tokens < 1000000) {
return `${(tokens / 1000).toFixed(1)}k`
}
if (tokens < 1000000000) {
return `${(tokens / 1000000).toFixed(1)}M`
}
return `${(tokens / 1000000000).toFixed(1)}B`
}

View file

@ -0,0 +1,48 @@
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export const formatCurrency = (amount: number) => formatter.format(amount)
export const formatDuration = (durationMs: number) => {
const seconds = Math.floor(durationMs / 1000)
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
const parts = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0) {
parts.push(`${minutes}m`)
}
if (remainingSeconds > 0 || parts.length === 0) {
parts.push(`${remainingSeconds}s`)
}
return parts.join(" ")
}
export const formatTokens = (tokens: number) => {
if (tokens < 1000) {
return tokens.toString()
}
if (tokens < 1000000) {
return `${(tokens / 1000).toFixed(1)}k`
}
if (tokens < 1000000000) {
return `${(tokens / 1000000).toFixed(1)}M`
}
return `${(tokens / 1000000000).toFixed(1)}B`
}
export const formatToolUsageSuccessRate = (usage: { attempts: number; failures: number }) =>
usage.attempts === 0 ? '0%' : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`

View file

@ -1,3 +0,0 @@
export { formatCurrency } from "./format-currency"
export { formatDuration } from "./format-duration"
export { formatTokens } from "./format-tokens"

1
evals/packages/db/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
test.db

View file

@ -0,0 +1 @@
ALTER TABLE `taskMetrics` ADD `toolUsage` text;

View file

@ -0,0 +1,296 @@
{
"version": "6",
"dialect": "sqlite",
"id": "61d48d20-f662-445d-9962-cf9cb165cbe7",
"prevId": "f49d9b0b-fda9-467a-9adb-c941d6cbf7ce",
"tables": {
"runs": {
"name": "runs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"taskMetricsId": {
"name": "taskMetricsId",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"socketPath": {
"name": "socketPath",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"concurrency": {
"name": "concurrency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 2
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"runs_taskMetricsId_taskMetrics_id_fk": {
"name": "runs_taskMetricsId_taskMetrics_id_fk",
"tableFrom": "runs",
"tableTo": "taskMetrics",
"columnsFrom": ["taskMetricsId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"taskMetrics": {
"name": "taskMetrics",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"tokensIn": {
"name": "tokensIn",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokensOut": {
"name": "tokensOut",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tokensContext": {
"name": "tokensContext",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cacheWrites": {
"name": "cacheWrites",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cacheReads": {
"name": "cacheReads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cost": {
"name": "cost",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toolUsage": {
"name": "toolUsage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"runId": {
"name": "runId",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taskMetricsId": {
"name": "taskMetricsId",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exercise": {
"name": "exercise",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"startedAt": {
"name": "startedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finishedAt": {
"name": "finishedAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tasks_language_exercise_idx": {
"name": "tasks_language_exercise_idx",
"columns": ["runId", "language", "exercise"],
"isUnique": true
}
},
"foreignKeys": {
"tasks_runId_runs_id_fk": {
"name": "tasks_runId_runs_id_fk",
"tableFrom": "tasks",
"tableTo": "runs",
"columnsFrom": ["runId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"tasks_taskMetricsId_taskMetrics_id_fk": {
"name": "tasks_taskMetricsId_taskMetrics_id_fk",
"tableFrom": "tasks",
"tableTo": "taskMetrics",
"columnsFrom": ["taskMetricsId"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -22,6 +22,13 @@
"when": 1743698195142,
"tag": "0002_white_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1744950664129,
"tag": "0003_sweet_chimera",
"breakpoints": true
}
]
}

View file

@ -6,6 +6,7 @@
"scripts": {
"lint": "eslint src/**/*.ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest --globals --run",
"format": "prettier --write src",
"drizzle-kit": "dotenvx run -f ../../.env -- tsx node_modules/drizzle-kit/bin.cjs",
"db:generate": "pnpm drizzle-kit generate",
@ -29,6 +30,8 @@
"devDependencies": {
"@evals/eslint-config": "workspace:^",
"@evals/typescript-config": "workspace:^",
"drizzle-kit": "^0.30.5"
"drizzle-kit": "^0.30.5",
"execa": "^9.5.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,87 @@
import { createRun, finishRun } from "../runs.js"
import { createTask } from "../tasks.js"
import { createTaskMetrics } from "../taskMetrics.js"
describe("finishRun", () => {
it("aggregates task metrics, including tool usage", async () => {
const run = await createRun({ model: "gpt-4.1-mini", socketPath: "/tmp/roo.sock" })
await createTask({
runId: run.id,
taskMetricsId: (
await createTaskMetrics({
duration: 45_000,
tokensIn: 100_000,
tokensOut: 2_000,
tokensContext: 102_000,
cacheWrites: 0,
cacheReads: 0,
cost: 0.05,
toolUsage: {
read_file: {
attempts: 3,
failures: 0,
},
apply_diff: {
attempts: 3,
failures: 1,
},
},
})
).id,
language: "go",
exercise: "go/say",
passed: true,
startedAt: new Date(),
finishedAt: new Date(),
})
await createTask({
runId: run.id,
taskMetricsId: (
await createTaskMetrics({
duration: 30_000,
tokensIn: 75_000,
tokensOut: 1_000,
tokensContext: 76_000,
cacheWrites: 0,
cacheReads: 0,
cost: 0.04,
toolUsage: {
read_file: {
attempts: 3,
failures: 0,
},
apply_diff: {
attempts: 2,
failures: 0,
},
},
})
).id,
language: "go",
exercise: "go/octal",
passed: true,
startedAt: new Date(),
finishedAt: new Date(),
})
const { taskMetrics } = await finishRun(run.id)
expect(taskMetrics).toEqual({
id: expect.any(Number),
tokensIn: 175000,
tokensOut: 3000,
tokensContext: 178000,
cacheWrites: 0,
cacheReads: 0,
cost: 0.09,
duration: 75000,
toolUsage: {
read_file: { attempts: 6, failures: 0 },
apply_diff: { attempts: 5, failures: 1 },
},
createdAt: expect.any(Date),
})
})
})

View file

@ -1,10 +1,13 @@
import { desc, eq, inArray, sql, sum } from "drizzle-orm"
import { ToolUsage } from "@evals/types"
import { RecordNotFoundError, RecordNotCreatedError } from "./errors.js"
import type { InsertRun, UpdateRun } from "../schema.js"
import { insertRunSchema, schema } from "../schema.js"
import { db } from "../db.js"
import { createTaskMetrics } from "./taskMetrics.js"
import { getTasks } from "./tasks.js"
const table = schema.runs
@ -71,17 +74,30 @@ export const finishRun = async (runId: number) => {
throw new RecordNotFoundError()
}
const tasks = await getTasks(runId)
const toolUsage = tasks.reduce((acc, task) => {
Object.entries(task.taskMetrics?.toolUsage || {}).forEach(([key, { attempts, failures }]) => {
const tool = key as keyof ToolUsage
acc[tool] ??= { attempts: 0, failures: 0 }
acc[tool].attempts += attempts
acc[tool].failures += failures
})
return acc
}, {} as ToolUsage)
const { passed, failed, ...rest } = values
const taskMetrics = await createTaskMetrics(rest)
const taskMetrics = await createTaskMetrics({ ...rest, toolUsage })
await updateRun(runId, { taskMetricsId: taskMetrics.id, passed, failed })
const run = await db.query.runs.findFirst({ where: eq(table.id, runId), with: { taskMetrics: true } })
const run = await findRun(runId)
if (!run) {
throw new RecordNotFoundError()
}
return run
return { ...run, taskMetrics }
}
export const deleteRun = async (runId: number) => {

View file

@ -2,7 +2,7 @@ import { sqliteTable, text, real, integer, blob, uniqueIndex } from "drizzle-orm
import { relations } from "drizzle-orm"
import { createInsertSchema } from "drizzle-zod"
import { RooCodeSettings, exerciseLanguages, rooCodeSettingsSchema } from "@evals/types"
import { RooCodeSettings, ToolUsage, exerciseLanguages, rooCodeSettingsSchema, toolUsageSchema } from "@evals/types"
/**
* runs
@ -84,12 +84,15 @@ export const taskMetrics = sqliteTable("taskMetrics", {
cacheReads: integer({ mode: "number" }).notNull(),
cost: real().notNull(),
duration: integer({ mode: "number" }).notNull(),
toolUsage: text({ mode: "json" }).$type<ToolUsage>(),
createdAt: integer({ mode: "timestamp" }).notNull(),
})
export type TaskMetrics = typeof taskMetrics.$inferSelect
export const insertTaskMetricsSchema = createInsertSchema(taskMetrics).omit({ id: true, createdAt: true })
export const insertTaskMetricsSchema = createInsertSchema(taskMetrics)
.omit({ id: true, createdAt: true })
.extend({ toolUsage: toolUsageSchema.optional() })
export type InsertTaskMetrics = Omit<typeof taskMetrics.$inferInsert, "id" | "createdAt">

View file

@ -1,5 +1,8 @@
{
"extends": "@evals/typescript-config/base.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globalSetup: ["./vitest.setup.ts"],
},
})

View file

@ -0,0 +1,20 @@
import fs from "node:fs/promises"
import path from "node:path"
import { execa } from "execa"
const TEST_DB_PATH = path.join(process.cwd(), "test.db")
export default async function () {
const exists = await fs.stat(TEST_DB_PATH).catch(() => false)
if (exists) {
await fs.unlink(TEST_DB_PATH)
}
await execa({
env: { BENCHMARKS_DB_PATH: `file:${TEST_DB_PATH}` },
})`pnpm db:push`
process.env.BENCHMARKS_DB_PATH = `file:${TEST_DB_PATH}`
}

View file

@ -59,6 +59,7 @@ export const rooCodeDefaults: RooCodeSettings = {
search_and_replace: false,
insert_content: false,
powerSteering: false,
append_to_file: false,
},
language: "en",

View file

@ -271,7 +271,7 @@ export type CustomSupportPrompts = z.infer<typeof customSupportPromptsSchema>
* ExperimentId
*/
export const experimentIds = ["search_and_replace", "insert_content", "powerSteering"] as const
export const experimentIds = ["search_and_replace", "insert_content", "powerSteering", "append_to_file"] as const
export const experimentIdsSchema = z.enum(experimentIds)
@ -285,6 +285,7 @@ const experimentsSchema = z.object({
search_and_replace: z.boolean(),
insert_content: z.boolean(),
powerSteering: z.boolean(),
append_to_file: z.boolean(),
})
export type Experiments = z.infer<typeof experimentsSchema>
@ -802,6 +803,49 @@ export const tokenUsageSchema = z.object({
export type TokenUsage = z.infer<typeof tokenUsageSchema>
/**
* ToolName
*/
export const toolNames = [
"execute_command",
"read_file",
"write_to_file",
"append_to_file",
"apply_diff",
"insert_content",
"search_and_replace",
"search_files",
"list_files",
"list_code_definition_names",
"browser_action",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"attempt_completion",
"switch_mode",
"new_task",
"fetch_instructions",
] as const
export const toolNamesSchema = z.enum(toolNames)
export type ToolName = z.infer<typeof toolNamesSchema>
/**
* ToolUsage
*/
export const toolUsageSchema = z.record(
toolNamesSchema,
z.object({
attempts: z.number(),
failures: z.number(),
}),
)
export type ToolUsage = z.infer<typeof toolUsageSchema>
/**
* RooCodeEvent
*/
@ -837,7 +881,7 @@ export const rooCodeEventsSchema = z.object({
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
[RooCodeEventName.TaskAborted]: z.tuple([z.string()]),
[RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskCompleted]: z.tuple([z.string(), tokenUsageSchema]),
[RooCodeEventName.TaskCompleted]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]),
})

6
evals/pnpm-lock.yaml generated
View file

@ -274,6 +274,12 @@ importers:
drizzle-kit:
specifier: ^0.30.5
version: 0.30.5
execa:
specifier: ^9.5.2
version: 9.5.2
vitest:
specifier: ^3.0.9
version: 3.0.9(@types/node@20.17.24)(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.3)
packages/ipc:
dependencies:

View file

@ -15,9 +15,7 @@
],
"tasks": {
"lint": {},
"check-types": {
"dependsOn": []
},
"check-types": {},
"test": {},
"format": {},
"dev": {

View file

@ -13,7 +13,7 @@ import { serializeError } from "serialize-error"
import * as vscode from "vscode"
// schemas
import { TokenUsage } from "../schemas"
import { TokenUsage, ToolUsage, ToolName } from "../schemas"
// api
import { ApiHandler, buildApiHandler } from "../api"
@ -39,7 +39,7 @@ import { GlobalFileNames } from "../shared/globalFileNames"
import { defaultModeSlug, getModeBySlug, getFullModeDetails, isToolAllowedForMode } from "../shared/modes"
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
import { formatLanguage } from "../shared/language"
import { ToolParamName, ToolName, ToolResponse } from "../shared/tools"
import { ToolParamName, ToolResponse } from "../shared/tools"
// services
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
@ -106,8 +106,8 @@ export type ClineEvents = {
taskAskResponded: []
taskAborted: []
taskSpawned: [taskId: string]
taskCompleted: [taskId: string, usage: TokenUsage]
taskTokenUsageUpdated: [taskId: string, usage: TokenUsage]
taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage]
taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage]
}
export type ClineOptions = {
@ -189,6 +189,9 @@ export class Cline extends EventEmitter<ClineEvents> {
private didAlreadyUseTool = false
private didCompleteReadingStream = false
// metrics
private toolUsage: ToolUsage = {}
constructor({
provider,
apiConfiguration,
@ -366,19 +369,15 @@ export class Cline extends EventEmitter<ClineEvents> {
this.emit("message", { action: "updated", message: partialMessage })
}
getTokenUsage() {
const usage = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
this.emit("taskTokenUsageUpdated", this.taskId, usage)
return usage
}
private async saveClineMessages() {
try {
const taskDir = await this.ensureTaskDirectoryExists()
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await fs.writeFile(filePath, JSON.stringify(this.clineMessages))
// combined as they are in ChatView
const apiMetrics = this.getTokenUsage()
const tokenUsage = this.getTokenUsage()
this.emit("taskTokenUsageUpdated", this.taskId, tokenUsage)
const taskMessage = this.clineMessages[0] // first message is always the task say
const lastRelevantMessage =
this.clineMessages[
@ -403,11 +402,11 @@ export class Cline extends EventEmitter<ClineEvents> {
number: this.taskNumber,
ts: lastRelevantMessage.ts,
task: taskMessage.text ?? "",
tokensIn: apiMetrics.totalTokensIn,
tokensOut: apiMetrics.totalTokensOut,
cacheWrites: apiMetrics.totalCacheWrites,
cacheReads: apiMetrics.totalCacheReads,
totalCost: apiMetrics.totalCost,
tokensIn: tokenUsage.totalTokensIn,
tokensOut: tokenUsage.totalTokensOut,
cacheWrites: tokenUsage.totalCacheWrites,
cacheReads: tokenUsage.totalCacheReads,
totalCost: tokenUsage.totalCost,
size: taskDirSize,
workspace: this.cwd,
})
@ -2693,4 +2692,26 @@ export class Cline extends EventEmitter<ClineEvents> {
public getFileContextTracker(): FileContextTracker {
return this.fileContextTracker
}
// Metrics
public getTokenUsage() {
return getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
}
public recordToolUsage({ toolName, success = true }: { toolName: ToolName; success?: boolean }) {
if (!this.toolUsage[toolName]) {
this.toolUsage[toolName] = { attempts: 0, failures: 0 }
}
this.toolUsage[toolName].attempts++
if (!success) {
this.toolUsage[toolName].failures++
}
}
public getToolUsage() {
return this.toolUsage
}
}

View file

@ -3,7 +3,6 @@
import * as os from "os"
import * as path from "path"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { Anthropic } from "@anthropic-ai/sdk"

View file

@ -1,4 +1,7 @@
// npx jest src/core/__tests__/CodeActionProvider.test.ts
import * as vscode from "vscode"
import { CodeActionProvider, ACTION_NAMES } from "../CodeActionProvider"
import { EditorUtils } from "../EditorUtils"

View file

@ -1,4 +1,7 @@
// npx jest src/core/__tests__/EditorUtils.test.ts
import * as vscode from "vscode"
import { EditorUtils } from "../EditorUtils"
// Use simple classes to simulate VSCode's Range and Position behavior.

View file

@ -1,3 +1,5 @@
// npx jest src/core/__tests__/mode-validator.test.ts
import { isToolAllowedForMode, getModeConfig, modes, ModeConfig } from "../../shared/modes"
import { TOOL_GROUPS } from "../../shared/tools"
import { validateToolUse } from "../mode-validator"

View file

@ -1,11 +1,14 @@
// npx jest src/core/__tests__/read-file-maxReadFileLine.test.ts
import * as path from "path"
import { countFileLines } from "../../integrations/misc/line-counter"
import { readLines } from "../../integrations/misc/read-lines"
import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/extract-text"
import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
import { isBinaryFile } from "isbinaryfile"
import { ReadFileToolUse } from "../../shared/tools"
import { ToolUsage } from "../../schemas"
// Mock dependencies
jest.mock("../../integrations/misc/line-counter")
@ -69,7 +72,6 @@ describe("read_file tool with maxReadFileLine setting", () => {
const mockedCountFileLines = countFileLines as jest.MockedFunction<typeof countFileLines>
const mockedReadLines = readLines as jest.MockedFunction<typeof readLines>
const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction<typeof extractTextFromFile>
const mockedAddLineNumbers = addLineNumbers as jest.MockedFunction<typeof addLineNumbers>
const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction<
typeof parseSourceCodeDefinitionsForFile
>
@ -125,6 +127,7 @@ describe("read_file tool with maxReadFileLine setting", () => {
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
trackFileContext: jest.fn().mockResolvedValue(undefined),
})
mockCline.recordToolUsage = jest.fn().mockReturnValue({} as ToolUsage)
// Reset tool result
toolResult = undefined

View file

@ -1,3 +1,5 @@
// npx jest src/core/__tests__/read-file-tool.test.ts
import * as path from "path"
import { countFileLines } from "../../integrations/misc/line-counter"
import { readLines } from "../../integrations/misc/read-lines"

View file

@ -1,3 +1,5 @@
// npx jest src/core/__tests__/read-file-xml.test.ts
import * as path from "path"
import { countFileLines } from "../../integrations/misc/line-counter"
@ -6,6 +8,7 @@ import { extractTextFromFile } from "../../integrations/misc/extract-text"
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
import { isBinaryFile } from "isbinaryfile"
import { ReadFileToolUse } from "../../shared/tools"
import { ToolUsage } from "../../schemas"
// Mock dependencies
jest.mock("../../integrations/misc/line-counter")
@ -118,6 +121,7 @@ describe("read_file tool XML output structure", () => {
mockCline.getFileContextTracker = jest.fn().mockReturnValue({
trackFileContext: jest.fn().mockResolvedValue(undefined),
})
mockCline.recordToolUsage = jest.fn().mockReturnValue({} as ToolUsage)
// Reset tool result
toolResult = undefined

View file

@ -1,4 +1,5 @@
import { TextContent, ToolUse, ToolParamName, toolParamNames, toolNames, ToolName } from "../../shared/tools"
import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
import { toolNames, ToolName } from "../../schemas"
export type AssistantMessageContent = TextContent | ToolUse

View file

@ -1,4 +1,4 @@
import { ToolName } from "../shared/tools"
import { ToolName } from "../schemas"
import { Mode, isToolAllowedForMode, ModeConfig } from "../shared/modes"
export function validateToolUse(

View file

@ -1,3 +1,10 @@
import { ToolName } from "../../../schemas"
import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools"
import { DiffStrategy } from "../../diff/DiffStrategy"
import { McpHub } from "../../../services/mcp/McpHub"
import { Mode, ModeConfig, getModeConfig, isToolAllowedForMode, getGroupName } from "../../../shared/modes"
import { ToolArgs } from "./types"
import { getExecuteCommandDescription } from "./execute-command"
import { getReadFileDescription } from "./read-file"
import { getFetchInstructionsDescription } from "./fetch-instructions"
@ -15,11 +22,6 @@ import { getUseMcpToolDescription } from "./use-mcp-tool"
import { getAccessMcpResourceDescription } from "./access-mcp-resource"
import { getSwitchModeDescription } from "./switch-mode"
import { getNewTaskDescription } from "./new-task"
import { DiffStrategy } from "../../diff/DiffStrategy"
import { McpHub } from "../../../services/mcp/McpHub"
import { Mode, ModeConfig, getModeConfig, isToolAllowedForMode, getGroupName } from "../../../shared/modes"
import { ToolName, TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools"
import { ToolArgs } from "./types"
// Map of tool names to their description functions
const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {

View file

@ -6,6 +6,7 @@ import { executeCommandTool } from "../executeCommandTool"
import { Cline } from "../../Cline"
import { formatResponse } from "../../prompts/responses"
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../../shared/tools"
import { ToolUsage } from "../../../schemas"
// Mock dependencies
jest.mock("../../Cline")
@ -40,6 +41,7 @@ describe("executeCommandTool", () => {
// @ts-expect-error - Jest mock function type issues
validateCommand: jest.fn().mockReturnValue(null),
},
recordToolUsage: jest.fn().mockReturnValue({} as ToolUsage),
}
// @ts-expect-error - Jest mock function type issues

View file

@ -13,6 +13,7 @@ export async function accessMcpResourceTool(
) {
const server_name: string | undefined = block.params.server_name
const uri: string | undefined = block.params.uri
try {
if (block.partial) {
const partialMessage = JSON.stringify({
@ -20,32 +21,42 @@ export async function accessMcpResourceTool(
serverName: removeClosingTag("server_name", server_name),
uri: removeClosingTag("uri", uri),
} satisfies ClineAskUseMcpServer)
await cline.ask("use_mcp_server", partialMessage, block.partial).catch(() => {})
return
} else {
if (!server_name) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "access_mcp_resource", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("access_mcp_resource", "server_name"))
return
}
if (!uri) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "access_mcp_resource", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("access_mcp_resource", "uri"))
return
}
cline.consecutiveMistakeCount = 0
const completeMessage = JSON.stringify({
type: "access_mcp_resource",
serverName: server_name,
uri,
} satisfies ClineAskUseMcpServer)
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
return
}
// now execute the tool
// Now execute the tool
await cline.say("mcp_server_request_started")
const resourceResult = await cline.providerRef.deref()?.getMcpHub()?.readResource(server_name, uri)
const resourceResultPretty =
resourceResult?.contents
.map((item) => {
@ -57,15 +68,19 @@ export async function accessMcpResourceTool(
.filter(Boolean)
.join("\n\n") || "(Empty response)"
// handle images (image must contain mimetype and blob)
// Handle images (image must contain mimetype and blob)
let images: string[] = []
resourceResult?.contents.forEach((item) => {
if (item.mimeType?.startsWith("image") && item.blob) {
images.push(item.blob)
}
})
await cline.say("mcp_server_response", resourceResultPretty, images)
pushToolResult(formatResponse.toolResult(resourceResultPretty, images))
cline.recordToolUsage({ toolName: "access_mcp_resource" })
return
}
} catch (error) {

View file

@ -23,11 +23,13 @@ export async function appendToFileTool(
) {
const relPath: string | undefined = block.params.path
let newContent: string | undefined = block.params.content
if (!relPath || !newContent) {
return
}
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await cline.say("rooignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
@ -48,6 +50,7 @@ export async function appendToFileTool(
if (newContent.startsWith("```")) {
newContent = newContent.split("\n").slice(1).join("\n").trim()
}
if (newContent.endsWith("```")) {
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
}
@ -68,36 +71,44 @@ export async function appendToFileTool(
try {
if (block.partial) {
// update gui message
// Update GUI message
const partialMessage = JSON.stringify(sharedMessageProps)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
// update editor
// Update editor
if (!cline.diffViewProvider.isEditing) {
await cline.diffViewProvider.open(relPath)
}
// If file exists, append newContent to existing content
if (fileExists && cline.diffViewProvider.originalContent) {
newContent = cline.diffViewProvider.originalContent + "\n" + newContent
}
// editor is open, stream content in
// Editor is open, stream content in
await cline.diffViewProvider.update(
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
false,
)
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "append_to_file", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("append_to_file", "path"))
await cline.diffViewProvider.reset()
return
}
if (!newContent) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "append_to_file", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("append_to_file", "content"))
await cline.diffViewProvider.reset()
return
}
cline.consecutiveMistakeCount = 0
if (!cline.diffViewProvider.isEditing) {
@ -125,17 +136,21 @@ export async function appendToFileTool(
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
: undefined,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await cline.diffViewProvider.revertChanges()
return
}
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true
if (userEdits) {
@ -147,6 +162,7 @@ export async function appendToFileTool(
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
@ -162,7 +178,10 @@ export async function appendToFileTool(
} else {
pushToolResult(`The content was successfully appended to ${relPath.toPosix()}.${newProblemsMessage}`)
}
cline.recordToolUsage({ toolName: "append_to_file" })
await cline.diffViewProvider.reset()
return
}
} catch (error) {

View file

@ -35,33 +35,36 @@ export async function applyDiffTool(
try {
if (block.partial) {
// update gui message
// Update GUI message
let toolProgressStatus
if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) {
toolProgressStatus = cline.diffStrategy.getProgressStatus(block)
}
const partialMessage = JSON.stringify(sharedMessageProps)
await cline.ask("tool", partialMessage, block.partial, toolProgressStatus).catch(() => {})
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "apply_diff", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "path"))
return
}
if (!diffContent) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "apply_diff", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("apply_diff", "diff"))
return
}
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await cline.say("rooignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
return
}
@ -70,6 +73,7 @@ export async function applyDiffTool(
if (!fileExists) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "apply_diff", success: false })
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await cline.say("error", formattedError)
pushToolResult(formattedError)
@ -87,14 +91,15 @@ export async function applyDiffTool(
success: false,
error: "No diff strategy available",
}
let partResults = ""
if (!diffResult.success) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "apply_diff", success: false })
const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1
cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount)
let formattedError = ""
telemetryService.captureDiffApplicationError(cline.taskId, currentCount)
if (diffResult.failParts && diffResult.failParts.length > 0) {
@ -102,14 +107,18 @@ export async function applyDiffTool(
if (failPart.success) {
continue
}
const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : ""
formattedError = `<error_details>\n${
failPart.error
}${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n</error_details>`
partResults += formattedError
}
} else {
const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : ""
formattedError = `Unable to apply diff to file: ${absolutePath}\n\n<error_details>\n${
diffResult.error
}${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n</error_details>`
@ -118,12 +127,14 @@ export async function applyDiffTool(
if (currentCount >= 2) {
await cline.say("diff_error", formattedError)
}
pushToolResult(formattedError)
return
}
cline.consecutiveMistakeCount = 0
cline.consecutiveMistakeCountForApplyDiff.delete(relPath)
// Show diff view before asking for approval
cline.diffViewProvider.editType = "modify"
await cline.diffViewProvider.open(relPath)
@ -136,26 +147,33 @@ export async function applyDiffTool(
} satisfies ClineSayTool)
let toolProgressStatus
if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) {
toolProgressStatus = cline.diffStrategy.getProgressStatus(block, diffResult)
}
const didApprove = await askApproval("tool", completeMessage, toolProgressStatus)
if (!didApprove) {
await cline.diffViewProvider.revertChanges() // cline likely handles closing the diff view
await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view
return
}
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
// Used to determine if we should wait for busy terminal to update before sending api request
cline.didEditFile = true
let partFailHint = ""
if (diffResult.failParts && diffResult.failParts.length > 0) {
partFailHint = `But unable to apply all diff parts to file: ${absolutePath}, silently use <read_file> tool to check newest file version and re-apply diffs\n`
}
if (userEdits) {
await cline.say(
"user_feedback_diff",
@ -165,6 +183,7 @@ export async function applyDiffTool(
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
partFailHint +
@ -183,7 +202,10 @@ export async function applyDiffTool(
`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}\n` + partFailHint,
)
}
cline.recordToolUsage({ toolName: "apply_diff" })
await cline.diffViewProvider.reset()
return
}
} catch (error) {

View file

@ -13,6 +13,7 @@ export async function askFollowupQuestionTool(
) {
const question: string | undefined = block.params.question
const follow_up: string | undefined = block.params.follow_up
try {
if (block.partial) {
await cline.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
@ -20,13 +21,12 @@ export async function askFollowupQuestionTool(
} else {
if (!question) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "ask_followup_question", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("ask_followup_question", "question"))
return
}
type Suggest = {
answer: string
}
type Suggest = { answer: string }
let follow_up_json = {
question,
@ -39,11 +39,10 @@ export async function askFollowupQuestionTool(
}
try {
parsedSuggest = parseXml(follow_up, ["suggest"]) as {
suggest: Suggest[] | Suggest
}
parsedSuggest = parseXml(follow_up, ["suggest"]) as { suggest: Suggest[] | Suggest }
} catch (error) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "ask_followup_question", success: false })
await cline.say("error", `Failed to parse operations: ${error.message}`)
pushToolResult(formatResponse.toolError("Invalid operations xml format"))
return
@ -57,10 +56,11 @@ export async function askFollowupQuestionTool(
}
cline.consecutiveMistakeCount = 0
const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false)
await cline.say("user_feedback", text ?? "", images)
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
cline.recordToolUsage({ toolName: "ask_followup_question" })
return
}
} catch (error) {

View file

@ -26,8 +26,10 @@ export async function attemptCompletionTool(
) {
const result: string | undefined = block.params.result
const command: string | undefined = block.params.command
try {
const lastMessage = cline.clineMessages.at(-1)
if (block.partial) {
if (command) {
// the attempt_completion text is done, now we're getting command
@ -43,7 +45,7 @@ export async function attemptCompletionTool(
await cline.say("completion_result", removeClosingTag("result", result), undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
}
@ -55,6 +57,7 @@ export async function attemptCompletionTool(
} else {
if (!result) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "attempt_completion", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("attempt_completion", "result"))
return
}
@ -68,7 +71,7 @@ export async function attemptCompletionTool(
// Haven't sent a command message yet so first send completion_result then command.
await cline.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
}
// Complete command message.
@ -91,7 +94,7 @@ export async function attemptCompletionTool(
} else {
await cline.say("completion_result", result, undefined, false)
telemetryService.captureTaskCompleted(cline.taskId)
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage())
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.getToolUsage())
}
if (cline.parentTask) {
@ -136,13 +139,10 @@ export async function attemptCompletionTool(
})
toolResults.push(...formatResponse.imageBlocks(images))
cline.userMessageContent.push({
type: "text",
text: `${toolDescription()} Result:`,
})
cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
cline.userMessageContent.push(...toolResults)
cline.recordToolUsage({ toolName: "attempt_completion" })
return
}
} catch (error) {

View file

@ -21,14 +21,17 @@ export async function browserActionTool(
const coordinate: string | undefined = block.params.coordinate
const text: string | undefined = block.params.text
const size: string | undefined = block.params.size
if (!action || !browserActions.includes(action)) {
// checking for action to ensure it is complete and valid
if (!block.partial) {
// if the block is complete and we don't have a valid action cline is a mistake
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "browser_action", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "action"))
await cline.browserSession.closeBrowser()
}
return
}
@ -52,51 +55,63 @@ export async function browserActionTool(
} else {
// Initialize with empty object to avoid "used before assigned" errors
let browserActionResult: BrowserActionResult = {}
if (action === "launch") {
if (!url) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "browser_action", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "url"))
await cline.browserSession.closeBrowser()
return
}
cline.consecutiveMistakeCount = 0
const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) {
return
}
// NOTE: it's okay that we call cline message since the partial inspect_site is finished streaming. The only scenario we have to avoid is sending messages WHILE a partial message exists at the end of the messages array. For example the api_req_finished message would interfere with the partial message, so we needed to remove that.
// await cline.say("inspect_site_result", "") // no result, starts the loading spinner waiting for result
await cline.say("browser_action_result", "") // starts loading spinner
// NOTE: It's okay that we call cline message since the partial inspect_site is finished streaming.
// The only scenario we have to avoid is sending messages WHILE a partial message exists at the end of the messages array.
// For example the api_req_finished message would interfere with the partial message, so we needed to remove that.
// await cline.say("inspect_site_result", "") // No result, starts the loading spinner waiting for result
await cline.say("browser_action_result", "") // Starts loading spinner
await cline.browserSession.launchBrowser()
browserActionResult = await cline.browserSession.navigateToUrl(url)
} else {
if (action === "click" || action === "hover") {
if (!coordinate) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "browser_action", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "coordinate"))
await cline.browserSession.closeBrowser()
return // can't be within an inner switch
}
}
if (action === "type") {
if (!text) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "browser_action", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "text"))
await cline.browserSession.closeBrowser()
return
}
}
if (action === "resize") {
if (!size) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "browser_action", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("browser_action", "size"))
await cline.browserSession.closeBrowser()
return
}
}
cline.consecutiveMistakeCount = 0
await cline.say(
"browser_action",
JSON.stringify({
@ -107,6 +122,7 @@ export async function browserActionTool(
undefined,
false,
)
switch (action) {
case "click":
browserActionResult = await cline.browserSession.click(coordinate!)
@ -141,6 +157,7 @@ export async function browserActionTool(
case "scroll_up":
case "resize":
await cline.say("browser_action_result", JSON.stringify(browserActionResult))
pushToolResult(
formatResponse.toolResult(
`The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${
@ -149,6 +166,7 @@ export async function browserActionTool(
browserActionResult?.screenshot ? [browserActionResult.screenshot] : [],
),
)
break
case "close":
pushToolResult(
@ -156,8 +174,12 @@ export async function browserActionTool(
`The browser has been closed. You may now proceed to using other tools.`,
),
)
break
}
cline.recordToolUsage({ toolName: "browser_action" })
return
}
} catch (error) {

View file

@ -13,6 +13,7 @@ export async function executeCommandTool(
) {
let command: string | undefined = block.params.command
const customCwd: string | undefined = block.params.cwd
try {
if (block.partial) {
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
@ -20,6 +21,7 @@ export async function executeCommandTool(
} else {
if (!command) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "execute_command", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
return
}
@ -28,7 +30,6 @@ export async function executeCommandTool(
if (ignoredFileAttemptedToAccess) {
await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
return
}
@ -38,14 +39,20 @@ export async function executeCommandTool(
cline.consecutiveMistakeCount = 0
const didApprove = await askApproval("command", command)
if (!didApprove) {
return
}
const [userRejected, result] = await cline.executeCommandTool(command, customCwd)
if (userRejected) {
cline.didRejectTool = true
}
pushToolResult(result)
cline.recordToolUsage({ toolName: "execute_command" })
return
}
} catch (error) {

View file

@ -12,50 +12,51 @@ export async function fetchInstructionsTool(
pushToolResult: PushToolResult,
) {
const task: string | undefined = block.params.task
const sharedMessageProps: ClineSayTool = {
tool: "fetchInstructions",
content: task,
}
const sharedMessageProps: ClineSayTool = { tool: "fetchInstructions", content: task }
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: undefined,
} satisfies ClineSayTool)
const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!task) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "fetch_instructions", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("fetch_instructions", "task"))
return
}
cline.consecutiveMistakeCount = 0
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: task,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, content: task } satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
// now fetch the content and provide it to the agent.
// Bow fetch the content and provide it to the agent.
const provider = cline.providerRef.deref()
const mcpHub = provider?.getMcpHub()
if (!mcpHub) {
throw new Error("MCP hub not available")
}
const diffStrategy = cline.diffStrategy
const context = provider?.context
const content = await fetchInstructions(task, { mcpHub, diffStrategy, context })
if (!content) {
pushToolResult(formatResponse.toolError(`Invalid instructions request: ${task}`))
return
}
pushToolResult(content)
cline.recordToolUsage({ toolName: "fetch_instructions" })
return
}
} catch (error) {
await handleError("fetch instructions", error)

View file

@ -37,12 +37,14 @@ export async function insertContentTool(
// Validate required parameters
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "insert_content", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "path"))
return
}
if (!operations) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "insert_content", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("insert_content", "operations"))
return
}
@ -52,6 +54,7 @@ export async function insertContentTool(
if (!fileExists) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "insert_content", success: false })
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await cline.say("error", formattedError)
pushToolResult(formattedError)
@ -70,6 +73,7 @@ export async function insertContentTool(
}
} catch (error) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "insert_content", success: false })
await cline.say("error", `Failed to parse operations JSON: ${error.message}`)
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
return
@ -112,10 +116,7 @@ export async function insertContentTool(
await cline.diffViewProvider.update(updatedContent, true)
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, diff } satisfies ClineSayTool)
const didApprove = await cline
.ask("tool", completeMessage, false)
@ -133,6 +134,7 @@ export async function insertContentTool(
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true
if (!userEdits) {
@ -149,6 +151,7 @@ export async function insertContentTool(
console.debug("[DEBUG] User made edits, sending feedback diff:", userFeedbackDiff)
await cline.say("user_feedback_diff", userFeedbackDiff)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file:\n\n` +
@ -159,6 +162,8 @@ export async function insertContentTool(
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`${newProblemsMessage}`,
)
cline.recordToolUsage({ toolName: "insert_content" })
await cline.diffViewProvider.reset()
} catch (error) {
handleError("insert content", error)

View file

@ -17,29 +17,33 @@ export async function listCodeDefinitionNamesTool(
removeClosingTag: RemoveClosingTag,
) {
const relPath: string | undefined = block.params.path
const sharedMessageProps: ClineSayTool = {
tool: "listCodeDefinitionNames",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "list_code_definition_names", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("list_code_definition_names", "path"))
return
}
cline.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cline.cwd, relPath)
let result: string
try {
const stats = await fs.stat(absolutePath)
if (stats.isFile()) {
const fileResult = await parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController)
result = fileResult ?? "No source code definitions found in cline file."
@ -51,18 +55,20 @@ export async function listCodeDefinitionNamesTool(
} catch {
result = `${absolutePath}: does not exist or cannot be accessed.`
}
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: result,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "read_tool" as RecordSource)
}
pushToolResult(result)
cline.recordToolUsage({ toolName: "list_code_definition_names" })
return
}
} catch (error) {

View file

@ -21,6 +21,7 @@ import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } f
* conversation.
* @param removeClosingTag - A function that removes a closing tag from a string.
*/
export async function listFilesTool(
cline: Cline,
block: ToolUse,
@ -32,28 +33,31 @@ export async function listFilesTool(
const relDirPath: string | undefined = block.params.path
const recursiveRaw: string | undefined = block.params.recursive
const recursive = recursiveRaw?.toLowerCase() === "true"
const sharedMessageProps: ClineSayTool = {
tool: !recursive ? "listFilesTopLevel" : "listFilesRecursive",
path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relDirPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "list_files", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("list_files", "path"))
return
}
cline.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cline.cwd, relDirPath)
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
const { showRooIgnoredFiles = true } = (await cline.providerRef.deref()?.getState()) ?? {}
const result = formatResponse.formatFilesList(
absolutePath,
files,
@ -61,15 +65,16 @@ export async function listFilesTool(
cline.rooIgnoreController,
showRooIgnoredFiles,
)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: result,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, content: result } satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
pushToolResult(result)
cline.recordToolUsage({ toolName: "list_files" })
}
} catch (error) {
await handleError("listing files", error)

View file

@ -15,6 +15,7 @@ export async function newTaskTool(
) {
const mode: string | undefined = block.params.mode
const message: string | undefined = block.params.message
try {
if (block.partial) {
const partialMessage = JSON.stringify({
@ -22,23 +23,29 @@ export async function newTaskTool(
mode: removeClosingTag("mode", mode),
message: removeClosingTag("message", message),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!mode) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "new_task", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode"))
return
}
if (!message) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "new_task", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "message"))
return
}
cline.consecutiveMistakeCount = 0
// Verify the mode exists
const targetMode = getModeBySlug(mode, (await cline.providerRef.deref()?.getState())?.customModes)
if (!targetMode) {
pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`))
return
@ -49,6 +56,7 @@ export async function newTaskTool(
mode: targetMode.name,
content: message,
})
const didApprove = await askApproval("tool", toolMessage)
if (!didApprove) {
@ -74,6 +82,7 @@ export async function newTaskTool(
cline.emit("taskSpawned", newCline.taskId)
pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${message}`)
cline.recordToolUsage({ toolName: "new_task" })
// Set the isPaused flag to true so the parent
// task can wait for the sub-task to finish.

View file

@ -37,15 +37,13 @@ export async function readFileTool(
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: undefined,
} satisfies ClineSayTool)
const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "read_file", success: false })
const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "path")
pushToolResult(`<file><path></path><error>${errorMsg}</error></file>`)
return
@ -67,13 +65,16 @@ export async function readFileTool(
// Parse start_line if provided
if (startLineStr) {
startLine = parseInt(startLineStr)
if (isNaN(startLine)) {
// Invalid start_line
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "read_file", success: false })
await cline.say("error", `Failed to parse start_line: ${startLineStr}`)
pushToolResult(`<file><path>${relPath}</path><error>Invalid start_line value</error></file>`)
return
}
startLine -= 1 // Convert to 0-based index
}
@ -84,6 +85,7 @@ export async function readFileTool(
if (isNaN(endLine)) {
// Invalid end_line
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "read_file", success: false })
await cline.say("error", `Failed to parse end_line: ${endLineStr}`)
pushToolResult(`<file><path>${relPath}</path><error>Invalid end_line value</error></file>`)
return
@ -94,6 +96,7 @@ export async function readFileTool(
}
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await cline.say("rooignore_error", relPath)
const errorMsg = formatResponse.rooIgnoreError(relPath)
@ -103,6 +106,7 @@ export async function readFileTool(
// Create line snippet description for approval message
let lineSnippet = ""
if (isFullRead) {
// No snippet for full read
} else if (startLine !== undefined && endLine !== undefined) {
@ -127,12 +131,14 @@ export async function readFileTool(
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
// Count total lines in the file
let totalLines = 0
try {
totalLines = await countFileLines(absolutePath)
} catch (error) {
@ -163,6 +169,7 @@ export async function readFileTool(
content = res[0].length > 0 ? addLineNumbers(res[0]) : ""
const result = res[1]
if (result) {
sourceCodeDef = `${result}`
}
@ -211,9 +218,11 @@ export async function readFileTool(
else {
// For non-range reads, always show line range
let lines = totalLines
if (maxReadFileLine >= 0 && totalLines > maxReadFileLine) {
lines = maxReadFileLine
}
const lineRangeAttr = ` lines="1-${lines}"`
// Maintain exact format expected by tests
@ -228,6 +237,7 @@ export async function readFileTool(
// Format the result into the required XML structure
const xmlResult = `<file><path>${relPath}</path>\n${contentTag}${xmlInfo}</file>`
pushToolResult(xmlResult)
cline.recordToolUsage({ toolName: "read_file" })
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)

View file

@ -32,16 +32,20 @@ export async function searchAndReplaceTool(
path: removeClosingTag("path", relPath),
operations: removeClosingTag("operations", operations),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_and_replace", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "path"))
return
}
if (!operations) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_and_replace", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "operations"))
return
}
@ -51,6 +55,7 @@ export async function searchAndReplaceTool(
if (!fileExists) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_and_replace", success: false })
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await cline.say("error", formattedError)
pushToolResult(formattedError)
@ -69,11 +74,13 @@ export async function searchAndReplaceTool(
try {
parsedOperations = JSON.parse(operations)
if (!Array.isArray(parsedOperations)) {
throw new Error("Operations must be an array")
}
} catch (error) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_and_replace", success: false })
await cline.say("error", `Failed to parse operations JSON: ${error.message}`)
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
return
@ -132,18 +139,16 @@ export async function searchAndReplaceTool(
await cline.diffViewProvider.update(newContent, true)
cline.diffViewProvider.scrollToFirstDiff()
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diff,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, diff: diff } satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await cline.diffViewProvider.revertChanges() // cline likely handles closing the diff view
return
}
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
}
@ -158,6 +163,7 @@ export async function searchAndReplaceTool(
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
@ -171,7 +177,10 @@ export async function searchAndReplaceTool(
} else {
pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`)
}
cline.recordToolUsage({ toolName: "search_and_replace" })
await cline.diffViewProvider.reset()
return
}
} catch (error) {

View file

@ -17,33 +17,38 @@ export async function searchFilesTool(
const relDirPath: string | undefined = block.params.path
const regex: string | undefined = block.params.regex
const filePattern: string | undefined = block.params.file_pattern
const sharedMessageProps: ClineSayTool = {
tool: "searchFiles",
path: getReadablePath(cline.cwd, removeClosingTag("path", relDirPath)),
regex: removeClosingTag("regex", regex),
filePattern: removeClosingTag("file_pattern", filePattern),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: "",
} satisfies ClineSayTool)
const partialMessage = JSON.stringify({ ...sharedMessageProps, content: "" } satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relDirPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_files", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "path"))
return
}
if (!regex) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "search_files", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("search_files", "regex"))
return
}
cline.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cline.cwd, relDirPath)
const results = await regexSearchFiles(
cline.cwd,
absolutePath,
@ -51,15 +56,17 @@ export async function searchFilesTool(
filePattern,
cline.rooIgnoreController,
)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: results,
} satisfies ClineSayTool)
const completeMessage = JSON.stringify({ ...sharedMessageProps, content: results } satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
pushToolResult(results)
cline.recordToolUsage({ toolName: "search_files" })
return
}
} catch (error) {

View file

@ -15,6 +15,7 @@ export async function switchModeTool(
) {
const mode_slug: string | undefined = block.params.mode_slug
const reason: string | undefined = block.params.reason
try {
if (block.partial) {
const partialMessage = JSON.stringify({
@ -22,49 +23,57 @@ export async function switchModeTool(
mode: removeClosingTag("mode_slug", mode_slug),
reason: removeClosingTag("reason", reason),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!mode_slug) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "switch_mode", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("switch_mode", "mode_slug"))
return
}
cline.consecutiveMistakeCount = 0
// Verify the mode exists
const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes)
if (!targetMode) {
cline.recordToolUsage({ toolName: "switch_mode", success: false })
pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`))
return
}
// Check if already in requested mode
const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug
if (currentMode === mode_slug) {
cline.recordToolUsage({ toolName: "switch_mode", success: false })
pushToolResult(`Already in ${targetMode.name} mode.`)
return
}
const completeMessage = JSON.stringify({
tool: "switchMode",
mode: mode_slug,
reason,
})
const completeMessage = JSON.stringify({ tool: "switchMode", mode: mode_slug, reason })
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
return
}
// Switch the mode using shared handler
await cline.providerRef.deref()?.handleModeSwitch(mode_slug)
pushToolResult(
`Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${
targetMode.name
} mode${reason ? ` because: ${reason}` : ""}.`,
)
await delay(500) // delay to allow mode change to take effect before next tool is executed
cline.recordToolUsage({ toolName: "switch_mode" })
await delay(500) // Delay to allow mode change to take effect before next tool is executed
return
}
} catch (error) {

View file

@ -22,51 +22,60 @@ export async function useMcpToolTool(
toolName: removeClosingTag("tool_name", tool_name),
arguments: removeClosingTag("arguments", mcp_arguments),
} satisfies ClineAskUseMcpServer)
await cline.ask("use_mcp_server", partialMessage, block.partial).catch(() => {})
return
} else {
if (!server_name) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "switch_mode", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "server_name"))
return
}
if (!tool_name) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "use_mcp_tool", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"))
return
}
// arguments are optional, but if they are provided they must be valid JSON
// if (!mcp_arguments) {
// cline.consecutiveMistakeCount++
// pushToolResult(await cline.sayAndCreateMissingParamError("use_mcp_tool", "arguments"))
// return
// }
let parsedArguments: Record<string, unknown> | undefined
if (mcp_arguments) {
try {
parsedArguments = JSON.parse(mcp_arguments)
} catch (error) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "use_mcp_tool", success: false })
await cline.say("error", `Roo tried to use ${tool_name} with an invalid JSON argument. Retrying...`)
pushToolResult(
formatResponse.toolError(formatResponse.invalidMcpToolArgumentError(server_name, tool_name)),
)
return
}
}
cline.consecutiveMistakeCount = 0
const completeMessage = JSON.stringify({
type: "use_mcp_tool",
serverName: server_name,
toolName: tool_name,
arguments: mcp_arguments,
} satisfies ClineAskUseMcpServer)
const didApprove = await askApproval("use_mcp_server", completeMessage)
if (!didApprove) {
return
}
// now execute the tool
// Now execute the tool
await cline.say("mcp_server_request_started") // same as browser_action_result
const toolResult = await cline.providerRef
.deref()
?.getMcpHub()
@ -88,8 +97,11 @@ export async function useMcpToolTool(
})
.filter(Boolean)
.join("\n\n") || "(No response)"
await cline.say("mcp_server_response", toolResultPretty)
pushToolResult(formatResponse.toolResult(toolResultPretty))
cline.recordToolUsage({ toolName: "use_mcp_tool" })
return
}
} catch (error) {

View file

@ -25,6 +25,7 @@ export async function writeToFileTool(
const relPath: string | undefined = block.params.path
let newContent: string | undefined = block.params.content
let predictedLineCount: number | undefined = parseInt(block.params.line_count ?? "0")
if (!relPath || !newContent) {
// checking for newContent ensure relPath is complete
// wait so we can determine if it's a new file or editing an existing file
@ -32,15 +33,16 @@ export async function writeToFileTool(
}
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await cline.say("rooignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
return
}
// Check if file exists using cached map or fs.access
let fileExists: boolean
if (cline.diffViewProvider.editType !== undefined) {
fileExists = cline.diffViewProvider.editType === "modify"
} else {
@ -54,6 +56,7 @@ export async function writeToFileTool(
// cline handles cases where it includes language specifiers like ```python ```js
newContent = newContent.split("\n").slice(1).join("\n").trim()
}
if (newContent.endsWith("```")) {
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
}
@ -71,41 +74,51 @@ export async function writeToFileTool(
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
isOutsideWorkspace,
}
try {
if (block.partial) {
// update gui message
const partialMessage = JSON.stringify(sharedMessageProps)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
// update editor
if (!cline.diffViewProvider.isEditing) {
// open the editor and prepare to stream content in
await cline.diffViewProvider.open(relPath)
}
// editor is open, stream content in
await cline.diffViewProvider.update(
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
false,
)
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "write_to_file", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path"))
await cline.diffViewProvider.reset()
return
}
if (!newContent) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "write_to_file", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content"))
await cline.diffViewProvider.reset()
return
}
if (!predictedLineCount) {
cline.consecutiveMistakeCount++
cline.recordToolUsage({ toolName: "write_to_file", success: false })
pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "line_count"))
await cline.diffViewProvider.reset()
return
}
cline.consecutiveMistakeCount = 0
// if isEditingFile false, that means we have the full contents of the file already.
@ -117,10 +130,12 @@ export async function writeToFileTool(
await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor
await cline.diffViewProvider.open(relPath)
}
await cline.diffViewProvider.update(
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
true,
)
await delay(300) // wait for diff view to update
cline.diffViewProvider.scrollToFirstDiff()
@ -128,6 +143,7 @@ export async function writeToFileTool(
if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
if (cline.diffStrategy) {
await cline.diffViewProvider.revertChanges()
pushToolResult(
formatResponse.toolError(
`Content appears to be truncated (file has ${
@ -161,18 +177,23 @@ export async function writeToFileTool(
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
: undefined,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await cline.diffViewProvider.revertChanges()
return
}
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
// Track file edit operation
if (relPath) {
await cline.getFileContextTracker().trackFileContext(relPath, "roo_edited" as RecordSource)
}
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
if (userEdits) {
await cline.say(
"user_feedback_diff",
@ -182,6 +203,7 @@ export async function writeToFileTool(
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
@ -197,7 +219,10 @@ export async function writeToFileTool(
} else {
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`)
}
cline.recordToolUsage({ toolName: "write_to_file" })
await cline.diffViewProvider.reset()
return
}
} catch (error) {

View file

@ -296,12 +296,12 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
this.taskMap.delete(cline.taskId)
})
cline.on("taskCompleted", async (_, usage) => {
this.emit(RooCodeEventName.TaskCompleted, cline.taskId, usage)
cline.on("taskCompleted", async (_, tokenUsage, toolUsage) => {
this.emit(RooCodeEventName.TaskCompleted, cline.taskId, tokenUsage, toolUsage)
this.taskMap.delete(cline.taskId)
await this.fileLog(
`[${new Date().toISOString()}] taskCompleted -> ${cline.taskId} | ${JSON.stringify(usage, null, 2)}\n`,
`[${new Date().toISOString()}] taskCompleted -> ${cline.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`,
)
})

View file

@ -520,6 +520,12 @@ type RooCodeEvents = {
totalCost: number
contextTokens: number
},
{
[x: string]: {
attempts: number
failures: number
}
},
]
taskTokenUsageUpdated: [
string,

View file

@ -529,6 +529,12 @@ type RooCodeEvents = {
totalCost: number
contextTokens: number
},
{
[x: string]: {
attempts: number
failures: number
}
},
]
taskTokenUsageUpdated: [
string,

View file

@ -817,6 +817,45 @@ export const tokenUsageSchema = z.object({
export type TokenUsage = z.infer<typeof tokenUsageSchema>
export const toolNames = [
"execute_command",
"read_file",
"write_to_file",
"append_to_file",
"apply_diff",
"insert_content",
"search_and_replace",
"search_files",
"list_files",
"list_code_definition_names",
"browser_action",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"attempt_completion",
"switch_mode",
"new_task",
"fetch_instructions",
] as const
export const toolNamesSchema = z.enum(toolNames)
export type ToolName = z.infer<typeof toolNamesSchema>
/**
* ToolUsage
*/
export const toolUsageSchema = z.record(
toolNamesSchema,
z.object({
attempts: z.number(),
failures: z.number(),
}),
)
export type ToolUsage = z.infer<typeof toolUsageSchema>
/**
* RooCodeEvent
*/
@ -851,7 +890,7 @@ export const rooCodeEventsSchema = z.object({
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
[RooCodeEventName.TaskAborted]: z.tuple([z.string()]),
[RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskCompleted]: z.tuple([z.string(), tokenUsageSchema]),
[RooCodeEventName.TaskCompleted]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]),
})

View file

@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineAsk, ToolProgressStatus, ToolGroup } from "../schemas"
import { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "../schemas"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
@ -26,29 +26,6 @@ export interface TextContent {
partial: boolean
}
export const toolNames = [
"execute_command",
"read_file",
"write_to_file",
"append_to_file",
"apply_diff",
"insert_content",
"search_and_replace",
"search_files",
"list_files",
"list_code_definition_names",
"browser_action",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"attempt_completion",
"switch_mode",
"new_task",
"fetch_instructions",
] as const
export type ToolName = (typeof toolNames)[number]
export const toolParamNames = [
"command",
"path",
@ -167,14 +144,6 @@ export interface NewTaskToolUse extends ToolUse {
params: Partial<Pick<Record<ToolParamName, string>, "mode" | "message">>
}
export type ToolUsage = Record<
ToolName,
{
attempts: number
failures: number
}
>
// Define tool group configuration
export type ToolGroupConfig = {
tools: readonly string[]