How an AI Agent Actually Reads a File (It's Not the Model)
When you say to an Agent: "Check README.md for me, what is this project about?"
It seems like just a sentence, but behind it, it's not "the model opened the file itself." Large models run on the server side; they can neither see your disk nor naturally have file permissions. What really happens is: the model proposes a tool request, the program executes it locally, and then returns the result as new context to the model.
This article won't rush into piling up code. We'll first follow a real read_file call and thoroughly clarify the following questions:
- Why can't the model see local files?
- What are Tool Schema, Tool Call, and Tool Result?
- What are the responsibilities of the model, Harness, and tool function?
- Why must paths be validated and file sizes limited?
- Why does the current implementation only read once and not yet constitute a complete Agent Loop?
First, remember the most important sentence in the whole text:
What limits the model is not "intelligence," but that the current program has not yet connected it to tools, permissions, and a result feedback mechanism.
01|The model can see the context, but not your disk
Each time the model performs inference, what it can truly see is only the context within that request, for example:
- System Prompt;
- User question;
- Historical messages;
- Tool descriptions proactively attached by the Harness;
- Tool results that have already been returned.
The README.md, package.json, and source code directories on your computer do not automatically enter the model's context just because the user mentioned them.
So the model can understand the sentence "please read the README," but it doesn't know what is actually written inside the README. Without tools, it can only guess based on existing knowledge; no matter how accurate the guess, it is not the same as reading the real file.
This is also the first line for judging whether an Agent has "touched the real world": Do the facts in the answer come from the model's memory, or from a verifiable external read?
02|A single file read actually involves five participants
A single read_file seems simple, but it actually involves at least five participants:
- User: Proposes a goal, such as "How do I start this project?"
- Model: Judges whether it can answer based solely on the existing context, and which file is needed next.
- Harness: Organizes the model request, provides tools, validates calls, executes functions, and returns results.
- Tool Function: Turns structured parameters into a real operation, such as calling a file reading API.
- File System: Stores the real README, code, and configuration; it is the source of truth.
Their complete relationship is:
User asks → Harness requests model → Model returns Tool Call → Harness validates and executes → File system returns content → Harness returns Tool Result → Model generates answer
The most confusing point here is: The model is responsible for deciding, and the Harness is responsible for executing. The model does not directly execute local code, nor does it bypass the program to gain file permissions.
03|A tool is actually composed of two halves: a "manual" and an "executor"
To enable the model to use read_file, the program needs to prepare two things:
- Tool Schema: A manual for the model, describing what the tool is called, what it can do, and what parameters to pass.
- Tool Executor: An execution function called by the Harness, responsible for actually reading the file.
These two parts cannot replace each other.
With only a Schema and no executor, the model will propose a "read README" request, but the program cannot complete the action; with only an executor and no Schema, the program has the reading capability, but the model doesn't know when or in what format to apply for its use.
Many frameworks wrap this step as "registering a tool." But in the corresponding minimal implementation for this article, there is no extra tool registry: we simply define READ_FILE_TOOL, place it in the tools array of the model request, and upon receiving a call, main.ts explicitly dispatches it to readFileTool.
04|Tool Schema is a model-readable function contract
The core description of read_file can be simplified to:
{
type: 'function',
function: {
name: 'read_file',
description: 'Read the content of a text file in the current project.',
parameters: {
type: 'object',
properties: {
path: { type: 'string' }
},
required: ['path'],
additionalProperties: false
}
}
}
This contract answers at least four questions:
name: Which tool should the model call?description: What problem is this tool suitable for solving?properties: What are the parameters and their types?required: Which parameters cannot be omitted?
additionalProperties: false also expresses a stricter constraint: do not arbitrarily attach undeclared fields.
But note: Schema is only a calling format, not a security boundary. Even if the Schema requires path to be a string, the model could still send an empty string, an out-of-bounds path, or a non-existent file. Real security validation must be completed by the Harness and the tool executor.
05|The Harness gives the tool description to the model, not the file to the model
After the user asks a question, the Harness initiates the first model request:
const response = await client.chat.completions.create({
model,
messages: [systemMessage, userMessage],
tools: [READ_FILE_TOOL]
})
At this point, the model receives two types of information:
messages: What problem the user wants to solve;tools: If information is missing, what capabilities can be requested.
The model still hasn't seen the body of README.md. It only knows that a tool named read_file exists and that it needs to provide a path when calling it.
This step is much like giving the model a menu: the menu tells it "what can be ordered," but the dishes haven't been served yet.
06|A Tool Call is a structured action request, not an execution result
If the model determines it must read the README first, it won't fabricate the file content directly but will return a Tool Call. Conceptually, it's similar to:
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
}
}
Three details are important here:
nameindicates which tool the model wants to use;argumentsis usually a JSON string in the interface, and the program still needs to parse and validate it;idis used to accurately match the subsequent Tool Result with this call.
So a Tool Call is more like an "application form": the model expresses its next intended step, but the disk hasn't been read yet, and the execution right remains in the Harness's hands.
07|The Harness must first identify, validate, and then decide whether to execute
After receiving a Tool Call, the Harness should not directly execute the content given by the model as a trusted command. The minimal implementation performs three layers of checks in sequence:
if (toolCall.type !== 'function') { /* reject */ }
if (toolCall.function.name !== 'read_file') { /* reject */ }
const input = JSON.parse(toolCall.function.arguments)
if (typeof input.path !== 'string' || input.path.trim() === '') {
throw new Error('read_file requires a file path.')
}
They are respectively confirming:
- Is this a tool call type we support?
- Is this a tool we allow to execute?
- Can the parameters be parsed, and is
pathreally a non-empty string?
This reflects a very important principle in Agent engineering:
The model can propose actions, but the program must retain the final execution right.
When connecting high-risk tools like writing files, executing commands, or accessing networks in the future, this validation layer will need to add further rules for permissions, approval, timeouts, and auditing.
08|Path validation determines where the tool can read
Just validating that path is a string is not enough. Suppose the model sends:
../../secret.txt
If passed directly to the file API, it could escape the current project directory. The implementation code first converts both the working directory and the target path into absolute paths:
const workDir = resolve(process.cwd())
const targetPath = resolve(workDir, relativePath)
const isOutsideWorkDir =
targetPath !== workDir &&
!targetPath.startsWith(`${workDir}${sep}`)
After normalization via resolve, ./src/../README.md is restored to its real target, and ../../secret.txt also exposes that it has run outside the workspace. Only if the target equals the workspace, or is located under "workspace path + system separator," is it allowed to continue.
Why add ${sep}? Because judging only by string prefix can cause false positives: /project-evil starts with /project but is not a subdirectory of /project.
This boundary illustrates: Tool capability does not equal unlimited permission. read_file can read files, but it should only read the scope explicitly allowed by the current task.
09|What actually reads the file is the local executor
After passing validation, the Harness calls the local tool function:
const content = await readFile(targetPath)
if (content.length > 8_000) {
return content.subarray(0, 8_000).toString('utf8') +
'\n\n...[File too long, only returning the first 8000 bytes]...'
}
return content.toString('utf8')
What actually touches the disk here is the Node.js file API, not the model.
The code also limits a single read to 8,000 bytes. The reason is not that "the model can't read long files," but that tool output enters the model's context: the larger the file, the slower the request, the more tokens consumed, and the easier it is to crowd out truly important information.
This minimal implementation treats files as UTF-8 text; in a production environment, one usually needs to further consider binary files, encoding, timeouts, sensitive information, large log files, and reading by line or range.
If the file does not exist or the system denies access, the underlying read will throw an error; if the file is too large, this implementation does not fail but returns the first 8,000 bytes and explicitly marks it as "truncated."
10|The Tool Result must match the original Tool Call
After the file read is complete, the content hasn't automatically returned to the model. The Harness needs to initiate a second model request and bring back the complete conversation relationship:
messages: [
{ role: 'user', content: prompt },
{
role: 'assistant',
content: null,
tool_calls: [toolCall]
},
{
role: 'tool',
tool_call_id: toolCall.id,
content: fileContent
}
]
This is not simply sending another piece of "README content." assistant.tool_calls records what request the model just made, and tool.tool_call_id indicates which call this result is answering.
If multiple tool calls exist simultaneously, this ID becomes even more important: it prevents the model from mismatching the result of tool A to tool B.
Therefore, the essence of a Tool Result is: converting the execution result from the external world into context that the model can understand in the next round.
11|Why can it read files now, but it's not yet a complete Agent Loop?
Connecting the entire chain, a single read actually goes through two model requests:
- First request: The model determines if a tool is needed and returns a Tool Call.
- Local execution: The Harness validates the path, reads the file, and gets the Tool Result.
- Second request: The model receives the real content and then organizes the final answer.
This already forms the smallest "Decision → Action → Feedback" closed loop, but the corresponding implementation deliberately handles only one tool call:
- Only takes
tool_calls?.[0]; - Only supports
read_file; - After the second request gets the result, it directly generates an answer;
- If the model still wants to continue reading
package.json, the current flow has no loop to execute the tool again.
So, being able to call a tool once does not equal having an Agent Loop. A complete loop also requires repeatedly checking the model's return: if there is a Tool Call, continue executing and returning it; if there is no Tool Call, then end.
Corresponding to the implementation code
If you continue reading the accompanying implementation, you can use the following map to locate things:
| Concept | Implementation Location | Responsibility |
|---|---|---|
| Tool Schema | READ_FILE_TOOL in src/readFile.ts |
Tells the model the tool name, description, and parameters |
| Parameter & Path Validation | getPath, readFileTool |
Rejects empty paths and paths outside the workspace |
| Tool Provision | tools: [READ_FILE_TOOL] in src/chat.ts |
Places the tool description into the first model request |
| Tool Call Dispatch | src/main.ts |
Identifies the tool and calls the executor |
| Tool Result Return | answerAfterReadFile |
Returns the file content to the model using role: 'tool' |
Accompanying source code: powercode / demos / 02-read-file
Finally, let's wrap it up with one sentence:
The model does not directly read the file. It selects the tool and generates the request; the Harness controls permissions, executes the action, and then feeds the real result back to the model.
In the next article, we will continue to expand "one tool call" into a true Agent Loop, seeing how it continuously reads multiple files and completes a task step by step.