Building a Local-File Code Agent with LangChain JS and DeepSeek
Foreword
Many AI coding tools (Cursor, CodeGeeX, etc.) now have the ability to read local files and automatically analyze code. The underlying core is LLM Function Calling (Tool Calling). This article uses LangChain JS + the DeepSeek large model to hand-write a lightweight Agent that can read local files and automatically parse code. It does not use high-level Agent executors but manually walks through the complete chain of "model determines to call a tool → executes local I/O → tool result is sent back to the model → outputs code interpretation" to thoroughly understand the underlying principles of Tool calling.
Tech Stack Description
@langchain/openai: Unified LLM encapsulation, compatible with all large models following the OpenAI protocol.@langchain/core: Provides thetooldecorator and four standard message base classes.zod: Strong type validation for tool parameters, standardizing the format of parameters passed by the LLM.dotenv: Manages API keys to prevent hardcoding leaks.node:fs/promises: Node.js native asynchronous file read/write capability.
I. Prerequisite Environment and Dependency Installation
1.1 Project Initialization
bash
mkdir hello-langchain && cd hello-langchain
npm init -y
1.2 Install All Dependencies
bash
npm install dotenv @langchain/openai @langchain/core zod
1.3 Configure Environment Variables
Create a .env file in the project root directory and fill in your DeepSeek key:
env
DEEPSEEK_API_KEY=sk-xxxYourKeyxxx
II. Complete Runnable Source Code (Line-by-Line Commented Version)
File name: tool.mjs
javascript
// Load .env environment variables into process.env. Repeated imports have no effect; it executes only once.
import 'dotenv/config';
// Import the LangChain-wrapped OpenAI-compatible model class.
// Note: Although the name includes OpenAI, all models supporting the OpenAI interface format can be used (DeepSeek, Tongyi Qianwen, etc.)
import { ChatOpenAI } from '@langchain/openai';
// Import the tool definition function, used to wrap ordinary JS functions into standard tools recognizable by the LLM.
import { tool } from '@langchain/core/tools';
// Import LangChain's four standard message objects to maintain the complete conversation context.
import {
HumanMessage, // Message sent by the user, corresponding to role: 'user'
SystemMessage, // System prompt message, corresponding to role: 'system', sets the AI's role and rules
ToolMessage, // Tool execution result message, corresponding to role: 'tool', passes the tool's return value to the model
AIMessage // Message returned by the AI model, corresponding to role: 'assistant'
} from '@langchain/core/messages';
// Import Node.js native asynchronous file module, returns a Promise, can be used with await.
import fs from 'node:fs/promises';
// Import the Zod type validation library to constrain the format of tool input parameters.
import { z } from 'zod';
// ===================== 1. Initialize the Large Model Instance =====================
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash', // Specifies the model name to use
apiKey: process.env.DEEPSEEK_API_KEY, // Reads the key from environment variables; hardcoding is prohibited
temperature: 0, // Temperature value 0 = completely deterministic output, suitable for code and tool-calling scenarios
configuration: {
baseURL: 'https://api.deepseek.com/v1' // DeepSeek's OpenAI-compatible interface address
}
});
// ===================== 2. Define the File Reading Tool =====================
// tool() receives two parameters: ① the tool execution function ② tool metadata (the instruction manual for the model)
const readFileTool = tool(
// First parameter: The asynchronous logic that the tool actually executes
async ({ filePath }) => {
// Read the file at the specified path in UTF-8 format
const content = await fs.readFile(filePath, 'utf-8');
// Print a log to the console for easy debugging, to observe when the model calls the tool
console.log(`[Tool Call]read_file(${filePath}) successfully read ${content.length} bytes of content`);
// The return value will be wrapped into a ToolMessage and eventually passed back to the large model
return content;
},
// Second parameter: Tool metadata. The large model relies on this information to determine "when to call and what parameters to pass"
{
name: 'read_file', // Unique tool name. The model matches the tool by name when calling it.
description: `Reads the content of a local file. This tool must be called when the user requests to view code, analyze a file, or read a script; supports relative/absolute paths.`,
schema: z.string().describe('The file path to be read') // Parameter constraint: must be a string, with a description
}
);
// Tool list array. To add new tools later, simply append them to this array.
const tools = [readFileTool];
// ===================== 3. Bind Tools to the Model =====================
// bindTools injects the tool descriptions into the model request, letting the model know what tools are available.
const modelWithTools = model.bindTools(tools);
// ===================== 4. Initialize the Conversation Context Message Queue =====================
// The messages array saves the complete conversation history. Messages are appended to this array in each round of interaction.
const messages = [
// System prompt: Sets the AI's role, workflow, and behavioral constraints
new SystemMessage(`
You are a professional code assistant. You can only obtain local file content through the read_file tool. Strictly follow the process:
1. When the user requests to read/parse a file, you must call the read_file tool; fabricating code is prohibited.
2. Wait for the tool to return the complete file text.
3. Based on the real file content, perform logical breakdown, line-by-line commenting, and problem analysis.
Available tool: read_file reads local files
`),
// User's question: The entry point that triggers the tool call
new HumanMessage('Please read the tool.mjs file content and fully explain this LangChain tool-calling code')
];
// ===================== 5. Main Execution Flow (Complete Tool Calling Chain) =====================
async function runAgent() {
// First round invoke: Send the conversation history to the model, letting the model reason whether a tool call is needed.
// invoke = Initiates a complete model inference request and waits for all results to return.
let response = await modelWithTools.invoke(messages);
// Add the model's returned result to the conversation history to maintain context.
messages.push(response);
// Determine if the model returned a tool call instruction.
if (response.tool_calls?.length > 0) {
// Iterate through all tool calls (supports calling multiple tools simultaneously).
for (const toolCall of response.tool_calls) {
const { name, args, id } = toolCall;
let toolResult;
// Match by tool name and execute the corresponding tool logic.
if (name === 'read_file') {
// Call the tool's invoke method, pass in the arguments, and get the execution result.
toolResult = await readFileTool.invoke(args);
}
// Wrap the tool execution result into a ToolMessage. Must carry the tool_call_id.
// The model uses this id to match: which tool call does this result correspond to?
messages.push(new ToolMessage({
tool_call_id: id,
content: toolResult
}));
}
// Second round invoke: Send the tool results together to the model, so the model can generate the final answer based on the real file content.
const finalAns = await modelWithTools.invoke(messages);
console.log("\n===== AI Code Analysis Result =====");
console.log(finalAns.content);
} else {
// If no tool call is needed, directly print the model's text response.
console.log(response.content);
}
}
// Start the Agent
runAgent();
III. In-Depth Module-by-Module Breakdown of Core Code
3.1 Dependency Import Module: The Role of Each Package
Table
| Import Item | Core Role | Common Pitfalls |
|---|---|---|
dotenv/config |
Automatically reads the root .env file and injects variables into process.env |
Repeated imports are ineffective. Writing it twice in the code is redundant; not an error, but not recommended. |
ChatOpenAI |
Unified LLM call entry point, compatible with all OpenAI protocol models | When connecting to non-OpenAI models, baseURL must be changed, otherwise it will request the OpenAI official site. |
tool |
Standardized tool wrapping function, automatically generates tool descriptions that meet model requirements | The input parameter structure is fixed: the execution function first, then the metadata object. |
| Four Message Types | Unified conversation message format, maintains the context chain | ToolMessage must carry tool_call_id, otherwise the model cannot match the context. |
z (Zod) |
Runtime parameter validation, forces the LLM to pass parameters in the correct format | The clearer the schema description, the higher the accuracy of the model's parameter passing. |
3.2 Large Model Initialization: Why It Can Connect to DeepSeek This Way
DeepSeek officially provides an OpenAI-compatible interface, so you can directly use the ChatOpenAI class to connect by modifying only two configurations:
- Change
baseURLto DeepSeek's interface address. - Change
modelNameto DeepSeek's model name.
Key Configuration temperature: 0
- Value range 0~2. The larger the value, the more random and creative the output.
- For tool calling and code analysis scenarios, it must be set to 0 to prevent the model from fabricating file content or calling tools randomly.
3.3 Tool Definition: The Two-Part Structure is the Core
tool(execution function, metadata) is the standard way to define tools in LangChain. The two parts have distinct responsibilities:
Part One: Asynchronous Execution Function
- This is the tool's actual business logic, which can be reading a file, calling an API, or querying a database.
- Input parameters are generated and passed by the large model, and the return value is passed back to the large model.
- It must be an asynchronous function (async) because most tool operations are time-consuming I/O operations.
Part Two: Metadata Configuration
name: The tool's unique identifier. The model relies on it for matching when calling.description: The "instruction manual" for the model. The more detailed it is, the higher the model's call accuracy.schema: Zod parameter constraint, equivalent to telling the model "you must pass this type of parameter," accompanied by a natural language description.
3.4 bindTools: Equipping the Model with a "Toolbox"
js
const modelWithTools = model.bindTools(tools);
bindTools does two things:
- Formats all tool names, descriptions, and parameter formats according to the OpenAI tool-calling protocol.
- Automatically stuffs the tool list into the request body sent to the model each time it is called.
The bound modelWithTools has two possible outputs:
- No tool needed: Returns a normal text response.
- Tool needed: Returns a structured
tool_callsinstruction, with no text output.
3.5 Conversation Context: Why a Message Array is Needed
LLMs are stateless. The complete conversation history must be sent with every request for the model to know what was said before and what the tool returned.
The collaboration flow of the four message types:
SystemMessagesets the rules first.HumanMessageis the user's request.AIMessageis the model's reply "I want to call a tool."ToolMessagereturns the tool's execution result.- The model then outputs the final answer.
3.6 Main Flow: What the Two invokes Do Respectively
The entire flow calls invoke twice. This is the standard chain for tool calling:
First invoke: Determine whether to use a tool
- Input: System prompt + User question
- Model reasoning: Can this question be answered directly? Does it need to read a file?
- Output: Tool call instruction (including tool name, parameters, call ID)
Second invoke: Generate an answer after getting the result
- Input: All previous conversations + Tool execution result
- Model reasoning: Organize language to output code interpretation based on the real file content
- Output: Final answer in natural language
invokeis LangChain's unified execution entry point. Whether it's a model, a tool, or a chain, execution is done using.invoke(input), meaning "pass in input, execute logic, return the complete result."
IV. Key Knowledge Point: Analysis of the tool_calls Data Structure
A simplified example of the core structure of the AIMessage returned by the model:
json
{
"content": "",
"tool_calls": [
{
"name": "read_file",
"args": { "input": "tool.mjs" },
"id": "call_00_TjGbBoBzcvEKkL56qzbF9059"
}
],
"finish_reason": "tool_calls"
}
contentis empty: The model does not output text, only issues a tool call instruction.finish_reason: "tool_calls": The reason for terminating this round of conversation is that a tool call was triggered. The program must process the tool and then make a second request.id: The unique identifier for the tool call.ToolMessagemust carry the same ID, otherwise the model cannot associate the context.
V. Common Pitfalls and Solutions
5.1 ERR_MODULE_NOT_FOUND Cannot Find dotenv
- Cause: Dependencies not installed, or pnpm symlinks are broken after moving the project directory.
- Solution: Delete
node_modulesand the lock file, then re-executepnpm installornpm install.
5.2 Model Still Fabricates Code After Tool Call
- Check if
temperatureis set to 0. - Explicitly constrain in the SystemMessage: "Fabricating file content is prohibited; you must call the tool."
- Write the tool's description clearly to cover applicable scenarios.
5.3 Timer 'xxx' does not exist
The label names for console.time and console.timeEnd must be exactly identical, including case, otherwise a timer does not exist error will be thrown.
5.4 Promise Serial vs. Parallel Execution Time
- Variable form
const p = new Promise(): Executes immediately upon definition. Multiple Promises run in parallel by default for timing. - Function form
function getP(){return new Promise()}: Executes only when called. Separateawaitcalls are serial, and times add up. Promise.all([p1, p2]): Forces parallelism. Total time = the time of the slowest task.
VI. Extension: Multi-Tool Parallelism and Promise.all
Complex Agents may call multiple tools simultaneously. Serial execution can be very slow. Using Promise.all for concurrent execution can significantly speed things up:
js
// Wrap all tool calls into an array of Promises
const toolTasks = response.tool_calls.map(async (call) => {
let result;
if (call.name === 'read_file') result = await readFileTool.invoke(call.args);
// Expand matching for new tools here later
return new ToolMessage({
tool_call_id: call.id,
content: result
});
});
// Wait for all tool executions to complete in parallel
const toolMessages = await Promise.all(toolTasks);
// Add all to the context at once
messages.push(...toolMessages);
- Serial execution: Total time = sum of all tool execution times.
Promise.allparallel: Total time = the execution time of the single slowest tool.
VII. Summary
- Core logic of Tool calling: LLM identifies the scenario and issues an instruction → Local execution of I/O / API → Result is passed back to the model to generate the final answer.
tool()two-part structure: Business execution function + Zod parameter constraint + description metadata; none are dispensable.- Four message types maintain the complete context.
tool_call_idis the key to associating the tool with the model's context. ChatOpenAIis compatible with all OpenAI protocol large models. Switching models only requires modifyingbaseURLandmodelName.- Manually implementing the tool chain allows a thorough understanding of the underlying operating principles of code AIs like Cursor and CodeGeeX. It can later be easily extended with more Agent capabilities such as file writing, command execution, and knowledge base retrieval.