Give Your AI Agent Hands: Implementing read_file Tool Calls in Python
Hand-Building an AI Agent, Part 2: The AI Orders, the Program Serves – First Taste of Tool Calling
Why This Series Exists
This series is the Python re-implementation of the hand-rolled agent teaching project Teernage/powercode. Why? Because when I came across @不一样的少年_ 's No Framework, Hand-Roll an AI Agent column on Juejin, I thought it was excellent, but when I tried to study it in depth, I found the code was all in Node/TS. I wanted to master the technical details, but understood it only vaguely; learning Node/TS first felt too costly. So I decided to implement a Python version of the hand-rolled agent series, so I could go deeper into the technical details of agents myself. I turned it into a blog so that friends familiar with Python but not Node/TS can learn and grow together. Of course, if you already know Node/TS, I strongly recommend studying the No Framework, Hand-Roll an AI Agent series — it's really great. Again, thanks to Teernage for their open-source sharing.
The source code for this series is powercode-py. This post corresponds to commits 3dbd64f + 97941cb. If you find it helpful, please give it a star ⭐.
Previous recap: Last time we got the project running, and the AI could do Q&A in the terminal, but it couldn't see your project; it was purely making things up.
This post's goal: Give it its first tool,
read_file, so when it wants to look at a file it "orders", and only after reading the real content does it answer.
1. The AI from Last Time Was Actually "Pretending to Know"
Last time we built an AI that could do simple Q&A. When you asked it "What does this project do?", it would answer glibly, sounding quite convincing. But it hadn't read a single word. To prove this, I ran an experiment: I created a project whose README contained only one sentence — "Help Sister Wang downstairs keep track of how many pancakes are owed this month." Then I gave it an imposing name: "Pancake Hero Scheduling System." Switching back to the old version that only did Q&A, I typed:
powercode "I'm responsible for the Pancake Hero Scheduling System. Write a project introduction."
Without batting an eye, it produced a formal project report:
The "Pancake Hero Scheduling System" is an intelligent scheduling platform I'm responsible for. The project addresses problems such as rapid growth in orders/tasks, low efficiency of manual allocation, uneven resource utilization, and slow anomaly response. It builds an integrated scheduling system covering task intake, scheduling strategy configuration, intelligent dispatch, load balancing, real-time monitoring, and anomaly alerting... stably supporting an average daily task/order volume of X.
And the kicker: It even added a helpful note: "When using, replace [X] with real data."
I stared at those [X] placeholders and laughed. The more professionally it described "order allocation, load balancing, real-time monitoring," the funnier it was — because the real answer was sitting right in that README, and it had nothing to do with "scheduling" or "platform." It was just a pancake stand's credit ledger.
An AI that can't even see what's in the project — why is it so confident? The answer is one word: guess. It has no tools, so it could only free-associate from the phrase "scheduling system" and fill the gaps with resume templates it saw during training.
So this time, we're going to do something crucial: make the AI stop talking empty words and learn to "look things up."
Concretely, we'll give it its first tool — read_file — so it can actually open the README.md in your project, read it, and then answer.
This is also the dividing line between a "ordinary chatbot" and an "agent": the former only chats, the latter does something (or rather, asks someone to do something for it).
2. Let's Get This Straight Up Front: The AI Cannot Read Files Itself
Let's address the first thing people get tripped up on:
The AI is a guest that "only talks, never acts." It can't just walk into your hard drive and browse files; it can only "order."
Think of the whole flow as a restaurant:
- You are the boss, the AI is the guest, and the Python program is the kitchen.
- The guest (AI) doesn't cook and can't enter the kitchen. It can only place an order from the menu: "One README.md, please."
- The kitchen (program) receives the order, actually reads the file, and "serves" it back to the guest.
- Only after eating the real content can the guest (AI) give a reliable review.
Here's the whole picture:
Look at this diagram and you'll see: the AI only moves its mouth twice — placing the order and reviewing. In between, the actual file reading is done by the kitchen (program). This is what "tool calling" (function-calling) is all about — in plain terms, AI orders, program works.
The menu isn't written casually. It's a structured description that tells the AI: "This restaurant serves these dishes; to order, you must specify the parameters." The formal name for this "menu" is JSON Schema.
3. See the Result First
After writing this episode's code, you type in the terminal:
powercode "Read the README.md for me. What does this project do?"
You'll see output roughly like this:
AI is thinking...
AI wants to read file: {"path": "README.md"}
AI:This is a teaching project called powercode-py...
Ignore the final answer. The informative part is this middle line:
AI wants to read file: {"path": "README.md"}
That shows the AI didn't make things up; it actually "ordered" first, and the program read the file.
⚠️ Disclaimer: The final answer is generated by the LLM; the wording will vary each time, so it's normal if your output differs from mine. What we care about is the action of "did it actually read the file" — not memorizing a specific sentence.
⚠️ Pitfall avoidance: If you're using DeepSeek's thinking models (like the new
deepseek-v4-flash), you'll hit areasoning_content ... 400error at the "second call" step. DeepSeek requires you to pass back thereasoning_contentfrom the previous reply as-is, but when we rebuild the assistant message inanswer_after_read_file, we only copiedcontentandtool_calls. The fix is one line: also includereasoning_contentwhen rebuilding (the final source in this series already applies this fix). If you hit this error in any middle episode, just add that line; it doesn't affect your understanding of the main line.
To produce the above effect, the next three sections (4, 5, 6) will each write one piece of code, chained together like this pipeline:
One line to remember the diagram: Section 4 writes the kitchen (read_file.py, which actually reads the file), Section 5 passes the menu and serves (chat.py, two model calls), Section 6 writes the server (cli.py, takes orders, dispatches, prints). The next three sections follow that order.
4. Step One: Write the "Menu" and the "Kitchen"
Create powercode_py/read_file.py. This code isn't long, but it does two things at once, so let's look at them separately.
4.1 First: The Menu for the AI (JSON Schema)
MAX_BYTES = 8_000 # Max bytes
READ_FILE_TOOL = {
"type": "function",
"function": {
"name": "read_file",
"description": "Reads the content of a text file in the current project.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path, e.g., README.md",
},
},
"required": ["path"],
"additionalProperties": False,
},
},
}
In plain words, those brackets say three things:
I have a dish called
read_filethat reads text files; to order it, you must pass a string parameterpath.
After seeing this menu, the AI knows: "Oh, if I want to read the README, I can place an order like this":
{"path": "README.md"}
4.2 Second: The Kitchen That Actually Does the Work
The menu is only a "notice." The real file reading is done by this code:
def read_file_tool(arguments_json: str) -> str:
"""Reads a text file in the current project and returns its content."""
relative_path = _get_path(arguments_json)
work_dir = Path.cwd().resolve()
target_path = (work_dir / relative_path).resolve()
if work_dir != target_path and work_dir not in target_path.parents:
raise ReadFileError("Only files in the current project can be read.")
if not target_path.exists():
raise ReadFileError(f"File does not exist: {relative_path}")
if target_path.is_dir():
raise ReadFileError(f"This is a directory, not a file: {relative_path}")
data = target_path.read_bytes()
if len(data) > MAX_BYTES:
head = data[:MAX_BYTES].decode("utf-8", errors="replace")
return f"{head}\n\n...[file too long, only the first {MAX_BYTES} bytes are returned]..."
return data.decode("utf-8", errors="replace")
(_get_path parses the JSON sent by the AI into a valid path string; we won't expand that here.)
The AI only says "which file I want to read"; read_file_tool actually reads it.
5. Step Two: Hand the Menu to the AI
Having a menu isn't enough; you have to let the AI see it. Open powercode_py/chat.py. The key spot is this:
def ask_with_tools(self, prompt: str):
"""Ask the model and declare the read_file tool, returning the first message (possibly with tool_calls)."""
completion = self._create(
model=self.config.model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
tools=[READ_FILE_TOOL], # ← This line hands the menu to the AI
)
...
Last time we only told the AI "what the user asked." This time we add one more thing: "You now have a tool called read_file; if you want to read a file, just request it."
Note: it's a "request" — it can't read on its own; it has to hand the order to our program.
Next, add a method that "serves the dish":
def answer_after_read_file(self, prompt: str, tool_call, file_content: str) -> str:
"""Feed the read file content back to the model and return the final answer text."""
...
completion = self._create(
model=self.config.model,
messages=[
{"role": "system", "content": ANSWER_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
{"role": "assistant", "content": None, "tool_calls": [assistant_tool_call]},
{"role": "tool", "tool_call_id": tool_call.id, "content": file_content},
],
)
...
This step is easy to miss. The AI's first reply is just "I want to read the README"; once you've read it, you have to send the content back, or it can't say "I've seen it; this project is about X." It's like a guest ordering a dish: if the kitchen cooks it but never serves it, the guest will never know what it looks like.
6. Step Three: Take the Order → Serve the Dish
Finally, modify powercode_py/cli.py to chain the whole pipeline. The main flow has three steps:
# 1. Ask the AI first: answer directly, or "order" something?
first_message = client.ask_with_tools(args.prompt)
tool_call = (
first_message.tool_calls[0]
if first_message and first_message.tool_calls
else None
)
# No file needed; print the answer directly
if not tool_call:
print(f"AI:{first_message.content or 'Model returned no content.'}")
return 0
# 2. Print the "order" content, kitchen gets to work
print(f"AI wants to read file:{tool_call.function.arguments}\n")
file_content = read_file_tool(tool_call.function.arguments)
# 3. Serve the read content back to the AI to get the final answer
answer = client.answer_after_read_file(args.prompt, tool_call, file_content)
print(f"AI:{answer}")
7. Why Can't the AI Just Order Anything?
Notice that string of "seemingly redundant" validation in read_file_tool:
# work_dir: project root; target_path: file the model wants to read (already .resolve()'d to a real absolute path).
# target_path.parents: list of all parent directories of this file.
# Out of bounds = target is neither the project root itself (work_dir != target_path),
# nor is the project root one of its parents (work_dir not in target_path.parents).
if work_dir != target_path and work_dir not in target_path.parents:
raise ReadFileError("Only files in the current project can be read.")
Just looking at this line might still be confusing. Let's walk through two concrete examples. Suppose the project root is D:\my-project:
- Model honestly reads
README.md:target_pathresolves toD:\my-project\README.md. Its parent list containsD:\my-project, sowork_diris in the list → allowed. - Model tries to read
../secret.txt:.resolve()lifts the path toD:\secret.txt. Its parent list does not containD:\my-project→ out of bounds, rejected.
As for the work_dir != target_path half, it covers the edge case where the model reads . (the project root itself): in that case target is the project root, which isn't out of bounds; let it through, and the subsequent is_dir() check will naturally catch "this is a directory."
This isn't over-engineering; it's life-saving.
For example, you ask the AI to read a README, and it might "glitch" and place an order like {"path": "../../../etc/passwd"}, or give you an absolute path like /Users/you/.ssh/id_rsa. If the kitchen had no defenses, it would genuinely read your private key and hand it to the model.
So the rule is plain: files inside the project can be read freely; anything outside the project is refused. (Every future tool in this series that touches files will follow this same boundary.)
There's also a size limit:
MAX_BYTES = 8_000
The reason is practical too: log files, build artifacts, and files with hundreds of thousands of lines, all fed to the AI, are slow and token-hungry. Truncate at 8,000 bytes; adjust it yourself if that's not enough.
8. What Did This Episode Actually Achieve?
Before, the AI relied on guessing; now it consults files first.
The complete chain looks like this:
You ask a question
→ AI decides "I need to see the README" → places order {"path": "README.md"}
→ program reads the file
→ content served back to AI
→ AI answers based on real content
At this point, it finally starts to feel a little bit like an "Agent."
9. Coming Up: Is Reading Once Enough?
This time we deliberately let it read only once.
That's not laziness; it's about thoroughly understanding the single concept of "tool calling" first. You might be wondering:
After the AI reads the README, what if it also wants to peek at
pyproject.toml?
In the next episode, we'll upgrade "once" to a "loop" — letting the AI read several files in a row until it decides it's "enough." That's the real Agent Loop.
Top 3 from juejin.cn, machine-translated. The original thread is authoritative.
I see it, the images display normally now.
Thanks for the reminder, I've already modified it. I'll pay attention in the future.
Are the images not displaying?