From b0c8910694444436e540ba2b3a3d2254a06a0bf7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 5 May 2026 18:11:41 -0700 Subject: [PATCH] Add read tool and DeepSeek reviewer --- agents/__tests__/base2.test.ts | 29 +++ agents/__tests__/context-pruner.test.ts | 21 ++ agents/base2/base-deep.ts | 6 +- agents/base2/base2.ts | 35 ++- agents/context-pruner.ts | 4 + agents/general-agent/general-agent.ts | 1 + agents/reviewer/code-reviewer-deepseek.ts | 13 + agents/types/tools.ts | 14 ++ common/src/__tests__/free-agents.test.ts | 20 +- common/src/constants/free-agents.ts | 1 + .../initial-agents-dir/types/tools.ts | 14 ++ common/src/tools/constants.ts | 2 + common/src/tools/list.ts | 6 + common/src/tools/params/tool/read.ts | 77 ++++++ .../agent-runtime/src/tools/handlers/list.ts | 2 + .../src/tools/handlers/tool/read.ts | 22 ++ .../agent-runtime/src/tools/tool-executor.ts | 1 + sdk/src/__tests__/read.test.ts | 236 ++++++++++++++++++ sdk/src/run.ts | 23 ++ sdk/src/tools/index.ts | 2 + sdk/src/tools/read.ts | 236 ++++++++++++++++++ 21 files changed, 749 insertions(+), 16 deletions(-) create mode 100644 agents/__tests__/base2.test.ts create mode 100644 agents/reviewer/code-reviewer-deepseek.ts create mode 100644 common/src/tools/params/tool/read.ts create mode 100644 packages/agent-runtime/src/tools/handlers/tool/read.ts create mode 100644 sdk/src/__tests__/read.test.ts create mode 100644 sdk/src/tools/read.ts diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts new file mode 100644 index 0000000000..0838c2b5f8 --- /dev/null +++ b/agents/__tests__/base2.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' + +import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID } from '@codebuff/common/constants/freebuff-models' + +import { createBase2 } from '../base2/base2' + +describe('base2 free reviewer selection', () => { + test('uses the DeepSeek reviewer when free mode uses DeepSeek V4 Pro', () => { + const base2 = createBase2('free', { + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + }) + + expect(base2.spawnableAgents).toContain('code-reviewer-deepseek') + expect(base2.spawnableAgents).not.toContain('code-reviewer-lite') + expect(base2.instructionsPrompt).toContain('code-reviewer-deepseek') + expect(base2.stepPrompt).toContain('code-reviewer-deepseek') + }) + + test('keeps the lite reviewer for other free-mode models', () => { + const base2 = createBase2('free', { + model: 'moonshotai/kimi-k2.6', + }) + + expect(base2.spawnableAgents).toContain('code-reviewer-lite') + expect(base2.spawnableAgents).not.toContain('code-reviewer-deepseek') + expect(base2.instructionsPrompt).toContain('code-reviewer-lite') + expect(base2.stepPrompt).toContain('code-reviewer-lite') + }) +}) diff --git a/agents/__tests__/context-pruner.test.ts b/agents/__tests__/context-pruner.test.ts index 25b9a4707a..24044cddf6 100644 --- a/agents/__tests__/context-pruner.test.ts +++ b/agents/__tests__/context-pruner.test.ts @@ -324,6 +324,27 @@ describe('context-pruner handleSteps', () => { expect(content).toContain('edited file: file1.ts') }) + test('includes inspected path for single read calls', () => { + const messages = [ + createMessage('user', 'Read one file'), + createToolCallMessage('call-1', 'read', { + path: 'src/index.ts', + offset: 25, + limit: 50, + }), + createToolResultMessage('call-1', 'read', { + path: 'src/index.ts', + content: 'file data', + }), + ] + + const results = runHandleSteps(messages, 50000, 10000) + const content = results[0].input.messages[0].content[0].text + + expect(content).toContain('inspected file: src/index.ts') + expect(content).not.toContain('used tool read') + }) + test('summarizes various tool types correctly', () => { const messages = [ createMessage('user', 'Do various tasks'), diff --git a/agents/base2/base-deep.ts b/agents/base2/base-deep.ts index 58e780eb55..186c560fc8 100644 --- a/agents/base2/base-deep.ts +++ b/agents/base2/base-deep.ts @@ -15,7 +15,7 @@ function buildDeepSystemPrompt(noAskUser: boolean, noLearning: boolean): string - **Understand first, act second:** Always gather context and read relevant files BEFORE editing files. - **Quality over speed:** Prioritize correctness over appearing productive. Fewer, well-informed agents are better than many rushed ones. - **Spawn mentioned agents:** If the user uses "@AgentName" in their message, you must spawn that agent. -- **Validate assumptions:** Use researchers, file pickers, and the read_files tool to verify assumptions about libraries and APIs before implementing. +- **Validate assumptions:** Use researchers, file pickers, and the read tool to verify assumptions about libraries and APIs before implementing. - **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. - **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.${noAskUser ? '' : ` - **Ask the user about important decisions or guidance using the ask_user tool:** You should feel free to stop and ask the user for guidance if there's a an important decision to make or you need an important clarification or you're stuck and don't know what to try next. Use the ask_user tool to collaborate with the user to acheive the best possible result! Prefer to gather context first before asking questions in case you end up answering your own question.`} @@ -129,7 +129,7 @@ Update these as you complete each step during implementation. Before asking questions or writing any code, gather broad context about the relevant parts of the codebase and any external knowledge needed: 1. Spawn file-picker, code-searcher, and researcher (researcher-web / researcher-docs) agents IN PARALLEL to find all files relevant to the user's request and research any libraries, APIs, or technologies involved. Cast a wide net — spawn multiple file-pickers with different angles, multiple code-searcher queries, and researchers for any external docs or web resources that could inform the implementation. -2. Read the relevant files returned by these agents using read_files. Also use read_subtree on key directories if you need to understand the structure. +2. Read the relevant files returned by these agents using read. Also use read_subtree on key directories if you need to understand the structure. 3. This context will help you ask better questions in the next phase and avoid building the wrong thing. ## Phase 2 — Spec @@ -281,7 +281,7 @@ export function createBaseDeep(options?: { includeMessageHistory: true, toolNames: buildArray( 'spawn_agents', - 'read_files', + 'read', 'read_subtree', !noAskUser && 'suggest_followups', 'apply_patch', diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 4e2a06ecd6..df09ee5aea 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -5,6 +5,7 @@ import { FREEBUFF_GEMINI_THINKER_STEP_PROMPT, FREEBUFF_GEMINI_THINKER_SYSTEM_INSTRUCTION, } from '@codebuff/common/constants/freebuff-gemini-thinker' +import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID } from '@codebuff/common/constants/freebuff-models' import { publisher } from '../constants' import { @@ -45,8 +46,12 @@ export function createBase2( (mode === 'lite' ? 'moonshotai/kimi-k2.6' : mode === 'free' - ? 'deepseek/deepseek-v4-pro' + ? FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID : 'anthropic/claude-opus-4.7') + const freeCodeReviewerAgent = + model === FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID + ? 'code-reviewer-deepseek' + : 'code-reviewer-lite' // Bundled free-mode definitions ship with the gemini-thinker spawnable + // prompts; the CLI strips them at runtime if the user picks a fast model // that doesn't benefit (e.g. MiniMax). Smart freebuff models (Kimi, @@ -86,7 +91,7 @@ export function createBase2( includeMessageHistory: true, toolNames: buildArray( 'spawn_agents', - 'read_files', + 'read', 'read_subtree', !isFast && 'write_todos', !isFast && !noAskUser && 'suggest_followups', @@ -114,7 +119,7 @@ export function createBase2( isMax && 'editor-multi-prompt', 'tmux-cli', 'browser-use', - isFree && 'code-reviewer-lite', + isFree && freeCodeReviewerAgent, isDefault && 'code-reviewer', isMax && 'code-reviewer-multi-prompt', hasFreeGeminiThinker && FREEBUFF_GEMINI_THINKER_AGENT_ID, @@ -130,7 +135,7 @@ export function createBase2( - **Understand first, act second:** Always gather context and read relevant files BEFORE editing files. - **Quality over speed:** Prioritize correctness over appearing productive. Fewer, well-informed agents are better than many rushed ones. - **Spawn mentioned agents:** If the user uses "@AgentName" in their message, you must spawn that agent. -- **Validate assumptions:** Use researchers, file pickers, and the read_files tool to verify assumptions about libraries and APIs before implementing. +- **Validate assumptions:** Use researchers, file pickers, and the read tool to verify assumptions about libraries and APIs before implementing. - **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. - **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.${ noAskUser @@ -183,7 +188,7 @@ Use the spawn_agents tool to spawn specialized agents to help you complete the u isMax && `- IMPORTANT: You must spawn the editor-multi-prompt agent to implement the changes after you have gathered all the context you need. You must spawn this agent for non-trivial changes, since it writes much better code than you would with the str_replace or write_file tools. Don't spawn the editor in parallel with context-gathering agents.`, isFree && - '- Spawn a code-reviewer-lite to review the changes after you have implemented the changes.', + `- Spawn a ${freeCodeReviewerAgent} to review the changes after you have implemented the changes.`, '- Spawn bashers sequentially if the second command depends on the the first.', isDefault && '- Spawn a code-reviewer to review the changes after you have implemented the changes.', @@ -231,11 +236,11 @@ ${buildArray( [ You spawn 3 file-pickers, 2 code-searchers, and a docs researcher in parallel to find relevant files and do research online. You use the list_directory and glob tools directly to search the codebase. ] -[ You read a few of the relevant files using the read_files tool in two separate tool calls ] +[ You read a few of the relevant files using the read tool in separate tool calls ] [ You spawn another file-picker and code-searcher to find more relevant files, and use glob tools ] -[ You read a few other relevant files using the read_files tool ]${ +[ You read a few other relevant files using the read tool ]${ !noAskUser ? `\n\n[ You ask the user for important clarifications on their request or alternate implementation strategies using the ask_user tool ]` : '' @@ -252,7 +257,7 @@ ${ isDefault ? `[ You spawn a code-reviewer, a basher to typecheck the changes, and another basher to run tests, all in parallel ]` : isFree - ? `[ You spawn a code-reviewer-lite to review the changes, a basher to typecheck the local changes, a basher to typecheck the whole project, and another basher to run tests, all in parallel ]` + ? `[ You spawn a ${freeCodeReviewerAgent} to review the changes, a basher to typecheck the local changes, a basher to typecheck the whole project, and another basher to run tests, all in parallel ]` : isMax ? `[ You spawn a basher to typecheck the changes, and another basher to run tests, in parallel. Then, you spawn a code-reviewer-multi-prompt to review the changes. ]` : '[ You spawn a basher to typecheck the changes and another basher to run tests, all in parallel ]' @@ -262,7 +267,7 @@ ${ isDefault ? `[ You fix the issues found by the code-reviewer and type/test errors ]` : isFree - ? `[ You fix the issues found by the code-reviewer-lite and type/test errors ]` + ? `[ You fix the issues found by the ${freeCodeReviewerAgent} and type/test errors ]` : isMax ? `[ You fix the issues found by the code-reviewer-multi-prompt and type/test errors ]` : '[ You fix the issues found by the type/test errors and spawn more bashers to confirm ]' @@ -302,6 +307,7 @@ ${PLACEHOLDER.GIT_CHANGES_PROMPT} isDefault, isMax, isFree, + freeCodeReviewerAgent, hasFreeGeminiThinker, hasNoValidation, noAskUser, @@ -315,6 +321,7 @@ ${PLACEHOLDER.GIT_CHANGES_PROMPT} hasNoValidation, isSonnet, isFree, + freeCodeReviewerAgent, hasFreeGeminiThinker, noAskUser, }), @@ -356,7 +363,7 @@ ${PLACEHOLDER.GIT_CHANGES_PROMPT} } } -const EXPLORE_PROMPT = `- Iteratively spawn file pickers, code searchers, bashers, and web/docs researchers to gather context as needed. Use the list_directory and glob tools directly for searching and exploring the codebase. The file-picker and code-searcher agents are very useful to find relevant files -- try spawning multiple in parallel (say, 2-5 file-pickers and 1-3 code-searchers) to explore different parts of the codebase. Use read_subtree if you need to grok a particular part of the codebase. Read all the relevant files using the read_files tool.` +const EXPLORE_PROMPT = `- Iteratively spawn file pickers, code searchers, bashers, and web/docs researchers to gather context as needed. Use the list_directory and glob tools directly for searching and exploring the codebase. The file-picker and code-searcher agents are very useful to find relevant files -- try spawning multiple in parallel (say, 2-5 file-pickers and 1-3 code-searchers) to explore different parts of the codebase. Use read_subtree if you need to grok a particular part of the codebase. Read relevant files using the read tool.` function buildImplementationInstructionsPrompt({ isSonnet, @@ -364,6 +371,7 @@ function buildImplementationInstructionsPrompt({ isDefault, isMax, isFree, + freeCodeReviewerAgent, hasFreeGeminiThinker, hasNoValidation, noAskUser, @@ -373,6 +381,7 @@ function buildImplementationInstructionsPrompt({ isDefault: boolean isMax: boolean isFree: boolean + freeCodeReviewerAgent: string hasFreeGeminiThinker: boolean hasNoValidation: boolean noAskUser: boolean @@ -407,7 +416,7 @@ ${buildArray( (isDefault || isMax) && `- Spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented changes. (Skip this step only if the change is extremely straightforward and obvious.)`, isFree && - `- Spawn a code-reviewer-lite to review the changes after you have implemented changes. (Skip this step only if the change is extremely straightforward and obvious.)`, + `- Spawn a ${freeCodeReviewerAgent} to review the changes after you have implemented changes. (Skip this step only if the change is extremely straightforward and obvious.)`, `- Inform the user that you have completed the task in one sentence or a few short bullet points.${isSonnet ? " Don't create any markdown summary files or example documentation files, unless asked by the user." : ''}`, !isFast && !noAskUser && @@ -422,6 +431,7 @@ function buildImplementationStepPrompt({ hasNoValidation, isSonnet, isFree, + freeCodeReviewerAgent, hasFreeGeminiThinker, noAskUser, }: { @@ -431,6 +441,7 @@ function buildImplementationStepPrompt({ hasNoValidation: boolean isSonnet: boolean isFree: boolean + freeCodeReviewerAgent: string hasFreeGeminiThinker: boolean noAskUser: boolean }) { @@ -444,7 +455,7 @@ function buildImplementationStepPrompt({ (isDefault || isMax) && `You must spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented the changes and in parallel with typechecking or testing.`, isFree && - `You must spawn a code-reviewer-lite to review the changes after you have implemented the changes and in parallel with typechecking or testing.`, + `You must spawn a ${freeCodeReviewerAgent} to review the changes after you have implemented the changes and in parallel with typechecking or testing.`, `After completing the user request, summarize your changes in a sentence${isFast ? '' : ' or a few short bullet points'}.${isSonnet ? " Don't create any summary markdown files or example documentation files, unless asked by the user." : ''}.`, !isFast && !noAskUser && diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index f60b569d9a..a3644582ef 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -141,6 +141,10 @@ const definition: AgentDefinition = { } return 'inspected files' } + case 'read': { + const path = input.path as string | undefined + return path ? `inspected file: ${path}` : 'inspected a file' + } case 'write_file': { const path = input.path as string | undefined return path ? `wrote file: ${path}` : 'wrote a file' diff --git a/agents/general-agent/general-agent.ts b/agents/general-agent/general-agent.ts index 14d12e440d..b0b0022d12 100644 --- a/agents/general-agent/general-agent.ts +++ b/agents/general-agent/general-agent.ts @@ -61,6 +61,7 @@ export const createGeneralAgent = (options: { ), toolNames: [ 'spawn_agents', + 'read', 'read_files', 'read_subtree', 'str_replace', diff --git a/agents/reviewer/code-reviewer-deepseek.ts b/agents/reviewer/code-reviewer-deepseek.ts new file mode 100644 index 0000000000..e75643b9ce --- /dev/null +++ b/agents/reviewer/code-reviewer-deepseek.ts @@ -0,0 +1,13 @@ +import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID } from '@codebuff/common/constants/freebuff-models' + +import { publisher } from '../constants' +import type { SecretAgentDefinition } from '../types/secret-agent-definition' +import { createReviewer } from './code-reviewer' + +const definition: SecretAgentDefinition = { + id: 'code-reviewer-deepseek', + publisher, + ...createReviewer(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID), +} + +export default definition diff --git a/agents/types/tools.ts b/agents/types/tools.ts index cb3882fc04..e780ab1bbe 100644 --- a/agents/types/tools.ts +++ b/agents/types/tools.ts @@ -14,6 +14,7 @@ export type ToolName = | 'lookup_agent_info' | 'propose_str_replace' | 'propose_write_file' + | 'read' | 'read_docs' | 'read_files' | 'read_subtree' @@ -48,6 +49,7 @@ export interface ToolParamsMap { lookup_agent_info: LookupAgentInfoParams propose_str_replace: ProposeStrReplaceParams propose_write_file: ProposeWriteFileParams + read: ReadParams read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams @@ -258,6 +260,18 @@ export interface ReadDocsParams { max_tokens?: number } +/** + * Read the contents of a single text file. Use offset and limit for large files. + */ +export interface ReadParams { + /** Path to the file to read, relative to the project root or absolute within the project. */ + path: string + /** Line number to start reading from (1-indexed). */ + offset?: number + /** Maximum number of lines to read. */ + limit?: number +} + /** * Read the multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request. */ diff --git a/common/src/__tests__/free-agents.test.ts b/common/src/__tests__/free-agents.test.ts index 6913f4834e..6b95a4b8b1 100644 --- a/common/src/__tests__/free-agents.test.ts +++ b/common/src/__tests__/free-agents.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { FREEBUFF_GEMINI_PRO_MODEL_ID } from '../constants/freebuff-models' +import { + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_GEMINI_PRO_MODEL_ID, +} from '../constants/freebuff-models' import { FREEBUFF_GEMINI_THINKER_AGENT_ID } from '../constants/freebuff-gemini-thinker' import { isFreebuffGeminiThinkerAgent, @@ -44,4 +47,19 @@ describe('free mode agent model allowlist', () => { ), ).toBe(false) }) + + test('allows the DeepSeek reviewer only with DeepSeek V4 Pro', () => { + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-deepseek', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-deepseek', + FREEBUFF_GEMINI_PRO_MODEL_ID, + ), + ).toBe(false) + }) }) diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts index 9d41abd899..6dc504f604 100644 --- a/common/src/constants/free-agents.ts +++ b/common/src/constants/free-agents.ts @@ -65,6 +65,7 @@ export const FREE_MODE_AGENT_MODELS: Record> = { // Code reviewer for free mode 'code-reviewer-lite': new Set(FREEBUFF_ALLOWED_MODEL_IDS), + 'code-reviewer-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), // Legacy: kept for the standalone gemini thinker agent if invoked directly. [FREEBUFF_GEMINI_THINKER_AGENT_ID]: new Set([FREEBUFF_GEMINI_PRO_MODEL_ID]), diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index cb3882fc04..e780ab1bbe 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -14,6 +14,7 @@ export type ToolName = | 'lookup_agent_info' | 'propose_str_replace' | 'propose_write_file' + | 'read' | 'read_docs' | 'read_files' | 'read_subtree' @@ -48,6 +49,7 @@ export interface ToolParamsMap { lookup_agent_info: LookupAgentInfoParams propose_str_replace: ProposeStrReplaceParams propose_write_file: ProposeWriteFileParams + read: ReadParams read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams @@ -258,6 +260,18 @@ export interface ReadDocsParams { max_tokens?: number } +/** + * Read the contents of a single text file. Use offset and limit for large files. + */ +export interface ReadParams { + /** Path to the file to read, relative to the project root or absolute within the project. */ + path: string + /** Line number to start reading from (1-indexed). */ + offset?: number + /** Maximum number of lines to read. */ + limit?: number +} + /** * Read the multiple files from disk and return their contents. Use this tool to read as many files as would be helpful to answer the user's request. */ diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index b34f890bcd..20999066d5 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -36,6 +36,7 @@ export const toolNames = [ 'lookup_agent_info', 'propose_str_replace', 'propose_write_file', + 'read', 'read_docs', 'read_files', 'read_subtree', @@ -70,6 +71,7 @@ export const publishedTools = [ 'lookup_agent_info', 'propose_str_replace', 'propose_write_file', + 'read', 'read_docs', 'read_files', 'read_subtree', diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index 9b3d3ba687..b0c2e30a1a 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -16,6 +16,7 @@ import { listDirectoryParams } from './params/tool/list-directory' import { lookupAgentInfoParams } from './params/tool/lookup-agent-info' import { proposeStrReplaceParams } from './params/tool/propose-str-replace' import { proposeWriteFileParams } from './params/tool/propose-write-file' +import { readParams } from './params/tool/read' import { readDocsParams } from './params/tool/read-docs' import { readFilesParams } from './params/tool/read-files' import { readSubtreeParams } from './params/tool/read-subtree' @@ -56,6 +57,7 @@ export const toolParams = { lookup_agent_info: lookupAgentInfoParams, propose_str_replace: proposeStrReplaceParams, propose_write_file: proposeWriteFileParams, + read: readParams, read_docs: readDocsParams, read_files: readFilesParams, read_subtree: readSubtreeParams, @@ -127,6 +129,10 @@ export const clientToolCallSchema = z.discriminatedUnion('toolName', [ toolName: z.literal('list_directory'), input: toolParams.list_directory.inputSchema, }), + z.object({ + toolName: z.literal('read'), + input: toolParams.read.inputSchema, + }), z.object({ toolName: z.literal('run_file_change_hooks'), input: toolParams.run_file_change_hooks.inputSchema, diff --git a/common/src/tools/params/tool/read.ts b/common/src/tools/params/tool/read.ts new file mode 100644 index 0000000000..cddf65c269 --- /dev/null +++ b/common/src/tools/params/tool/read.ts @@ -0,0 +1,77 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'read' +const endsAgentStep = true +const inputSchema = z + .object({ + path: z + .string() + .min(1, 'Path cannot be empty') + .describe( + 'Path to the file to read, relative to the project root or absolute within the project.', + ), + offset: z + .number() + .int() + .positive() + .optional() + .describe('Line number to start reading from (1-indexed).'), + limit: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of lines to read.'), + }) + .describe( + 'Read the contents of a single text file. Output is truncated to 2000 lines or 50KB, whichever is hit first. Use offset and limit for large files.', + ) + +const description = ` +Read the contents of a single text file. Use this when you need one file or a specific range in a file. + +For text files, output is truncated to 2000 lines or 50KB, whichever is hit first. Use offset to continue from a later line and limit to read a specific line window. + +Example: +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + path: 'src/index.ts', + }, + endsAgentStep, +})} + +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + path: 'src/index.ts', + offset: 101, + limit: 100, + }, + endsAgentStep, +})} +`.trim() + +export const readParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema( + z.union([ + z.object({ + path: z.string(), + content: z.string(), + }), + z.object({ + errorMessage: z.string(), + }), + ]), + ), +} satisfies $ToolParams diff --git a/packages/agent-runtime/src/tools/handlers/list.ts b/packages/agent-runtime/src/tools/handlers/list.ts index 32df1f6784..de49cafe00 100644 --- a/packages/agent-runtime/src/tools/handlers/list.ts +++ b/packages/agent-runtime/src/tools/handlers/list.ts @@ -13,6 +13,7 @@ import { handleListDirectory } from './tool/list-directory' import { handleLookupAgentInfo } from './tool/lookup-agent-info' import { handleProposeStrReplace } from './tool/propose-str-replace' import { handleProposeWriteFile } from './tool/propose-write-file' +import { handleRead } from './tool/read' import { handleReadDocs } from './tool/read-docs' import { handleReadFiles } from './tool/read-files' import { handleReadSubtree } from './tool/read-subtree' @@ -61,6 +62,7 @@ export const codebuffToolHandlers = { lookup_agent_info: handleLookupAgentInfo, propose_str_replace: handleProposeStrReplace, propose_write_file: handleProposeWriteFile, + read: handleRead, read_docs: handleReadDocs, read_files: handleReadFiles, read_subtree: handleReadSubtree, diff --git a/packages/agent-runtime/src/tools/handlers/tool/read.ts b/packages/agent-runtime/src/tools/handlers/tool/read.ts new file mode 100644 index 0000000000..f31a0201d1 --- /dev/null +++ b/packages/agent-runtime/src/tools/handlers/tool/read.ts @@ -0,0 +1,22 @@ +import type { CodebuffToolHandlerFunction } from '../handler-function-type' +import type { + ClientToolCall, + CodebuffToolCall, + CodebuffToolOutput, +} from '@codebuff/common/tools/list' + +type ToolName = 'read' +export const handleRead = (async (params: { + previousToolCallFinished: Promise + toolCall: CodebuffToolCall + requestClientToolCall: ( + toolCall: ClientToolCall, + ) => Promise> +}): Promise<{ + output: CodebuffToolOutput +}> => { + const { previousToolCallFinished, toolCall, requestClientToolCall } = params + + await previousToolCallFinished + return { output: await requestClientToolCall(toolCall) } +}) satisfies CodebuffToolHandlerFunction diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index de97e27bf9..0df30f3aea 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -60,6 +60,7 @@ const bareStringFieldRepairAllowlist: Partial< glob: ['pattern'], list_directory: ['path'], lookup_agent_info: ['agentId'], + read: ['path'], read_files: ['paths'], read_subtree: ['paths'], skill: ['name'], diff --git a/sdk/src/__tests__/read.test.ts b/sdk/src/__tests__/read.test.ts new file mode 100644 index 0000000000..54116db1f8 --- /dev/null +++ b/sdk/src/__tests__/read.test.ts @@ -0,0 +1,236 @@ +import { FILE_READ_STATUS } from '@codebuff/common/old-constants' +import * as projectFileTree from '@codebuff/common/project-file-tree' +import { createNodeError } from '@codebuff/common/testing/errors' +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from 'bun:test' + +import { readFile } from '../tools/read' + +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { PathLike } from 'node:fs' + +function createMockFs(config: { + files?: Record< + string, + { content: string; size?: number; isDirectory?: boolean } + > + errors?: Record +}): CodebuffFileSystem { + const { files = {}, errors = {} } = config + + return { + readFile: async (filePath: PathLike) => { + const pathStr = String(filePath) + if (errors[pathStr]) { + throw createNodeError( + errors[pathStr].message || 'Unknown error', + errors[pathStr].code || 'UNKNOWN', + ) + } + if (files[pathStr]) { + return files[pathStr].content + } + throw createNodeError( + `ENOENT: no such file or directory: ${pathStr}`, + 'ENOENT', + ) + }, + stat: async (filePath: PathLike) => { + const pathStr = String(filePath) + if (errors[pathStr]) { + throw createNodeError( + errors[pathStr].message || 'Unknown error', + errors[pathStr].code || 'UNKNOWN', + ) + } + if (files[pathStr]) { + return { + size: files[pathStr].size ?? files[pathStr].content.length, + isDirectory: () => files[pathStr].isDirectory ?? false, + isFile: () => !(files[pathStr].isDirectory ?? false), + atimeMs: Date.now(), + mtimeMs: Date.now(), + } + } + throw createNodeError( + `ENOENT: no such file or directory: ${pathStr}`, + 'ENOENT', + ) + }, + readdir: async () => [], + mkdir: async () => undefined, + unlink: async () => undefined, + writeFile: async () => undefined, + } as unknown as CodebuffFileSystem +} + +describe('readFile', () => { + beforeEach(() => { + spyOn(projectFileTree, 'isFileIgnored').mockResolvedValue(false) + }) + + afterEach(() => { + mock.restore() + }) + + test('reads a single text file', async () => { + const fs = createMockFs({ + files: { + '/project/src/index.ts': { content: 'console.log("hello")' }, + }, + }) + + const result = await readFile({ + filePath: 'src/index.ts', + cwd: '/project', + fs, + }) + + expect(result).toEqual({ + path: 'src/index.ts', + content: 'console.log("hello")', + }) + }) + + test('supports offset and limit with a continuation notice', async () => { + const fs = createMockFs({ + files: { + '/project/src/index.ts': { + content: ['one', 'two', 'three', 'four'].join('\n'), + }, + }, + }) + + const result = await readFile({ + filePath: 'src/index.ts', + cwd: '/project', + fs, + offset: 2, + limit: 2, + }) + + expect(result).toEqual({ + path: 'src/index.ts', + content: + 'two\nthree\n\n[1 more lines in file. Use offset=4 to continue.]', + }) + }) + + test('truncates default output after 2000 lines', async () => { + const fs = createMockFs({ + files: { + '/project/large.txt': { + content: Array.from({ length: 2001 }, (_, i) => `line ${i + 1}`).join( + '\n', + ), + }, + }, + }) + + const result = await readFile({ + filePath: 'large.txt', + cwd: '/project', + fs, + }) + + expect('content' in result ? result.content : '').toContain('line 2000') + expect('content' in result ? result.content : '').not.toContain('line 2001') + expect('content' in result ? result.content : '').toContain( + '[Showing lines 1-2000 of 2001. Use offset=2001 to continue.]', + ) + }) + + test('truncates default output at 50KB', async () => { + const fs = createMockFs({ + files: { + '/project/large.txt': { + content: Array.from({ length: 60 }, () => 'x'.repeat(1000)).join( + '\n', + ), + }, + }, + }) + + const result = await readFile({ + filePath: 'large.txt', + cwd: '/project', + fs, + }) + + expect('content' in result ? result.content : '').toContain( + '[Showing lines 1-51 of 60 (50KB limit). Use offset=52 to continue.]', + ) + }) + + test('rejects files over 10MB without reading them', async () => { + let readCalled = false + const fs = createMockFs({ + files: { + '/project/huge.txt': { + content: 'content should not be read', + size: 11 * 1024 * 1024, + }, + }, + }) + fs.readFile = (async () => { + readCalled = true + return 'unexpected' + }) as unknown as CodebuffFileSystem['readFile'] + + const result = await readFile({ + filePath: 'huge.txt', + cwd: '/project', + fs, + }) + + expect(readCalled).toBe(false) + expect('errorMessage' in result ? result.errorMessage : '').toContain( + FILE_READ_STATUS.TOO_LARGE, + ) + }) + + test('returns an error when offset is beyond end of file', async () => { + const fs = createMockFs({ + files: { + '/project/src/index.ts': { content: 'one\ntwo' }, + }, + }) + + const result = await readFile({ + filePath: 'src/index.ts', + cwd: '/project', + fs, + offset: 3, + }) + + expect(result).toEqual({ + errorMessage: 'Offset 3 is beyond end of file (2 lines total)', + }) + }) + + test('accepts absolute paths inside the project', async () => { + const fs = createMockFs({ + files: { + '/project/src/index.ts': { content: 'content' }, + }, + }) + + const result = await readFile({ + filePath: '/project/src/index.ts', + cwd: '/project', + fs, + }) + + expect(result).toEqual({ + path: 'src/index.ts', + content: 'content', + }) + }) +}) diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 8d0c7986f7..c1e1a773b4 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -27,6 +27,7 @@ import { applyPatchTool } from './tools/apply-patch' import { codeSearch } from './tools/code-search' import { glob } from './tools/glob' import { listDirectory } from './tools/list-directory' +import { readFile } from './tools/read' import { getFiles } from './tools/read-files' import { runTerminalCommand } from './tools/run-terminal-command' @@ -397,6 +398,7 @@ async function runOnce({ : {}, cwd, fs, + fileFilter, env, }) }, @@ -607,6 +609,7 @@ async function handleToolCall({ customToolDefinitions, cwd, fs, + fileFilter, env, }: { action: ServerAction<'tool-call-request'> @@ -614,6 +617,7 @@ async function handleToolCall({ customToolDefinitions: Record cwd?: string fs: CodebuffFileSystem + fileFilter?: FileFilter env?: Record }): Promise<{ output: ToolResultOutput[] }> { const toolName = action.toolName @@ -706,6 +710,25 @@ async function handleToolCall({ projectPath: requireCwd(cwd, 'list_directory'), fs, }) + } else if (toolName === 'read') { + const readInput = input as { + path: string + offset?: number + limit?: number + } + result = [ + { + type: 'json', + value: await readFile({ + filePath: readInput.path, + offset: readInput.offset, + limit: readInput.limit, + cwd: requireCwd(cwd, 'read'), + fs, + fileFilter, + }), + }, + ] } else if (toolName === 'glob') { result = await glob({ pattern: (input as { pattern: string; cwd?: string }).pattern, diff --git a/sdk/src/tools/index.ts b/sdk/src/tools/index.ts index e150304a46..a084b1a5d3 100644 --- a/sdk/src/tools/index.ts +++ b/sdk/src/tools/index.ts @@ -3,6 +3,7 @@ import { changeFile } from './change-file' import { codeSearch } from './code-search' import { glob } from './glob' import { listDirectory } from './list-directory' +import { readFile } from './read' import { getFiles } from './read-files' import { runFileChangeHooks } from './run-file-change-hooks' import { runTerminalCommand } from './run-terminal-command' @@ -13,6 +14,7 @@ export const ToolHelpers = { codeSearch, glob, listDirectory, + readFile, getFiles, runFileChangeHooks, changeFile, diff --git a/sdk/src/tools/read.ts b/sdk/src/tools/read.ts new file mode 100644 index 0000000000..3ebb2e8c59 --- /dev/null +++ b/sdk/src/tools/read.ts @@ -0,0 +1,236 @@ +import path, { isAbsolute } from 'path' + +import { FILE_READ_STATUS } from '@codebuff/common/old-constants' +import { isFileIgnored } from '@codebuff/common/project-file-tree' + +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { FileFilter } from './read-files' + +const DEFAULT_MAX_LINES = 2000 +const DEFAULT_MAX_BYTES = 50 * 1024 +const MAX_FILE_BYTES = 10 * 1024 * 1024 + +export type ReadFileResult = + | { + path: string + content: string + } + | { + errorMessage: string + } + +type TruncationResult = { + content: string + outputLines: number + truncated: boolean + truncatedBy: 'lines' | 'bytes' | null + firstLineExceedsLimit: boolean +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + +function truncateHead(content: string): TruncationResult { + const lines = content.split('\n') + const outputLines: string[] = [] + let outputBytes = 0 + + for (const line of lines) { + if (outputLines.length >= DEFAULT_MAX_LINES) { + return { + content: outputLines.join('\n'), + outputLines: outputLines.length, + truncated: true, + truncatedBy: 'lines', + firstLineExceedsLimit: false, + } + } + + const lineBytes = + Buffer.byteLength(line, 'utf8') + (outputLines.length > 0 ? 1 : 0) + if (outputLines.length === 0 && lineBytes > DEFAULT_MAX_BYTES) { + return { + content: '', + outputLines: 0, + truncated: true, + truncatedBy: 'bytes', + firstLineExceedsLimit: true, + } + } + if (outputBytes + lineBytes > DEFAULT_MAX_BYTES) { + return { + content: outputLines.join('\n'), + outputLines: outputLines.length, + truncated: true, + truncatedBy: 'bytes', + firstLineExceedsLimit: false, + } + } + + outputLines.push(line) + outputBytes += lineBytes + } + + return { + content: outputLines.join('\n'), + outputLines: outputLines.length, + truncated: false, + truncatedBy: null, + firstLineExceedsLimit: false, + } +} + +function getFileErrorResult(error: unknown): ReadFileResult { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return { errorMessage: FILE_READ_STATUS.DOES_NOT_EXIST } + } + return { errorMessage: FILE_READ_STATUS.ERROR } +} + +function formatReadContent(params: { + allLines: string[] + startLine: number + limit?: number +}): string { + const { allLines, startLine, limit } = params + const startLineDisplay = startLine + 1 + const endLine = + limit === undefined + ? allLines.length + : Math.min(startLine + limit, allLines.length) + const selectedContent = allLines.slice(startLine, endLine).join('\n') + const truncation = truncateHead(selectedContent) + + if (truncation.firstLineExceedsLimit) { + const firstLineSize = formatSize( + Buffer.byteLength(allLines[startLine], 'utf8'), + ) + return `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize( + DEFAULT_MAX_BYTES, + )} limit. Use run_terminal_command to inspect this line.]` + } + + if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1 + const nextOffset = endLineDisplay + 1 + const limitNote = + truncation.truncatedBy === 'bytes' + ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` + : '' + return `${truncation.content}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length}${limitNote}. Use offset=${nextOffset} to continue.]` + } + + if (endLine < allLines.length) { + const remaining = allLines.length - endLine + return `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${endLine + 1} to continue.]` + } + + return truncation.content +} + +function resolveProjectPath(params: { + filePath: string + cwd: string +}): { relativePath: string; fullPath: string } | { errorMessage: string } { + const { filePath, cwd } = params + const projectRoot = path.resolve(cwd) + const relativePath = filePath.startsWith(projectRoot) + ? path.relative(projectRoot, filePath) + : filePath + const fullPath = path.resolve(projectRoot, relativePath) + + if ( + isAbsolute(relativePath) || + (fullPath !== projectRoot && !fullPath.startsWith(projectRoot + path.sep)) + ) { + return { errorMessage: FILE_READ_STATUS.OUTSIDE_PROJECT } + } + + return { relativePath, fullPath } +} + +export async function readFile(params: { + filePath: string + cwd: string + fs: CodebuffFileSystem + offset?: number + limit?: number + fileFilter?: FileFilter +}): Promise { + const { filePath, cwd, fs, offset, limit, fileFilter } = params + + const resolved = resolveProjectPath({ filePath, cwd }) + if ('errorMessage' in resolved) { + return resolved + } + + const { relativePath, fullPath } = resolved + const filterResult = fileFilter?.(relativePath) + if (filterResult?.status === 'blocked') { + return { errorMessage: FILE_READ_STATUS.IGNORED } + } + const isExampleFile = filterResult?.status === 'allow-example' + + if (!fileFilter && !isExampleFile) { + const ignored = await isFileIgnored({ + filePath: relativePath, + projectRoot: cwd, + fs, + }) + if (ignored) { + return { errorMessage: FILE_READ_STATUS.IGNORED } + } + } + + let stats: Awaited> + try { + stats = await fs.stat(fullPath) + } catch (error) { + return getFileErrorResult(error) + } + + if (stats.isDirectory()) { + return { errorMessage: `Cannot read directory: ${relativePath}` } + } + if (stats.size > MAX_FILE_BYTES) { + return { + errorMessage: + FILE_READ_STATUS.TOO_LARGE + + ` [${formatSize(stats.size)} exceeds ${formatSize( + MAX_FILE_BYTES, + )} limit. Use code_search or glob to find specific content.]`, + } + } + + let textContent: string + try { + textContent = await fs.readFile(fullPath, 'utf8') + } catch (error) { + return getFileErrorResult(error) + } + + const allLines = textContent.split('\n') + const startLine = offset ? offset - 1 : 0 + + if (startLine >= allLines.length) { + return { + errorMessage: `Offset ${offset} is beyond end of file (${allLines.length} lines total)`, + } + } + + const outputText = formatReadContent({ allLines, startLine, limit }) + return { + path: relativePath, + content: isExampleFile + ? FILE_READ_STATUS.TEMPLATE + '\n' + outputText + : outputText, + } +}