第5章 道具をつくる① ファイルを読む・一覧する
📖 この章のゴール:本物の道具として
read_file(ファイルを読む)とlist_files(フォルダの中身を一覧する)の2つを作り、エージェントが自分でプロジェクトを調べられるようになる。読み取りは「壊さない」ので比較的安全——でも「どこまで読めてしまうのか?」という宿題が、ここでこっそり生まれます。 ← 目次・はじめにへもどる
📱 Claude Code ではこう見える
ターミナルで Claude Code に「このバグ直して」とだけ頼んでみると、いきなり修正案を出すのではなく、まずこんな動きをします。
- 「まず関係しそうなファイルを見ます」と言って、フォルダの中身を一覧する(
src/には何が入ってる?) - あたりを付けて、いくつかのファイルを勝手に開いて読む(
index.tsの中身は…) - 中身を読んで「ここが原因ですね」と把握してから、ようやく直しに入る
あなたは「直して」としか言っていないのに、Claude Code は頼まれてもいないのに、自分でプロジェクトを調べ始めます。これができるのは、Claude Code が「フォルダを一覧する道具」と「ファイルを読む道具」を持っていて、それを必要だと思ったタイミングで自分から使うからです。
第3章では「今の時刻を返す」だけの、いわばおもちゃの道具を作りました。この章で作るのは、エージェントが「まず状況を調べる」ために本当に使う、最初の実用的な道具です。前章までで「考える↔動く」のループ(背骨🔁)はできています。あとはそのループに良い道具を差し込むだけで、エージェントは賢く振る舞い始めます。
🤔 なぜ「読む」から始めるのか(🟢 基礎)
道具には「読む(調べる)」系と「書く・実行する(変える)」系があります。この章であえて読む系から始めるのには理由があります。
読み取りは「壊さない」ので、比較的安全だからです。
ファイルを読むだけなら、中身が消えたり書き換わったりはしません。コマンドを走らせるわけでもない。だから万一エージェントが変な動きをしても、最悪「見当ちがいのファイルを覗いた」くらいで、取り返しはつきます。道具づくりの最初の一歩にちょうどいいのです。
次章(第6章)で作る「書く」道具は、ここから一気に話が変わります。書く=上書きなので、大事なファイルを壊せてしまう。だから本書は、まず安全な読みで道具の作り方そのものに慣れ、危なさは1つずつ足していきます。
でも「比較的」安全、なのがミソ(🔧 応用・伏線)
「読み取りは安全」と言いましたが、「比較的」です。よく考えると、こわい穴が一つ空いています。
あなたは「このプロジェクトのファイルを読んでいい」と思って道具を渡します。 でも、もしエージェントが
read_file("../../.ssh/id_rsa")のように、作業フォルダの外を読もうとしたら?
..(ひとつ上のフォルダへ、の意味)をいくつも重ねれば、パソコンのどこのファイルでも読めてしまう——いまの素朴な作りでは、それを止めるものがありません。読み取りですら、放っておくと「プロジェクトの外の秘密ファイルまで覗ける道具」になりうるのです。
これは本書を貫く伏線です。この穴は第9章(サンドボックスと最小権限)でしっかり封じます。いまは「道具には“どこまで触っていいか”の境界が要る」と頭の隅に置いておけば十分。まずは道具を動かすことに集中しましょう。
💡 ここが背骨🔁の効きどころです。道具を1つ足すたびに、エージェントの能力が1段増えます。 時刻しか分からなかったエージェントが、
read_fileを持てば「ファイルの中身を踏まえて答える」ようになり、list_filesを持てば「どんなファイルがあるかを自分で調べる」ようになる。何ができるエージェントになるかは、あなたがどんな道具を渡すかで決まるのです。
🛠 こう作る(🟢 基礎)
部品は3つです。前章までのコードに足していくだけで、新しい仕組みはほとんど要りません。
- ファイル操作の標準ライブラリ
fs/promisesを読み込む tools(道具メニュー)にread_fileとlist_filesの定義を足すexecuteTool(実際に動かす係)に、その2つの中身を足す
① まずは fs/promises を読み込む
Node.js には、ファイルを読み書きするための機能が最初から入っています。その「待てる版(async/await で書ける版)」が fs/promises(えふえす・プロミセズ)です。
import * as fs from "node:fs/promises";
1行ずつ読むと:
import * as fs from "node:fs/promises";… Node.js 標準のファイル操作機能を、まるごとfsという名前で読み込みます。node:を頭に付けるのは「これは自分で入れたパッケージではなく、Node.js に最初から入っているもの」という目印です(yarn addなどのインストールは不要)。これ以降fs.readFile(...)やfs.readdir(...)のように使えます。
② 道具メニュー(tools)に2つ足す
第3章で tools の形は確立しました。LLMに「こんな道具が使えるよ」と知らせるためのメニュー(説明書き)です。そこへ2つ追加します。
const tools: Anthropic.Tool[] = [
{
name: "read_file",
description: "指定したパスのファイルの中身を、文字列で返して読む。",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "読みたいファイルのパス。例: src/index.ts" },
},
required: ["path"],
},
},
{
name: "list_files",
description: "指定したフォルダの中にあるファイル・フォルダの名前を一覧する。",
input_schema: {
type: "object",
properties: {
dir: { type: "string", description: "中身を見たいフォルダのパス。例: src" },
},
required: ["dir"],
},
},
];
1行ずつ読むと:
const tools: Anthropic.Tool[] = [… 道具メニューの配列です。中に道具の定義を並べます(第3章と同じ形)。name: "read_file",… 道具の名前。あとで「LLMがこの名前の道具を呼んだら…」とつなぐ目印になるので、ここの綴りは正確に。description: "...読む。",… この道具が何をするかの説明。LLMはこの説明文を読んで「いま使うべきか」を判断します。人に説明するつもりで書くのがコツ。input_schema: { ... }… この道具にどんな引数を渡すかの決まり。「object(かたまり)の形で渡してね」という宣言です。properties: { path: { type: "string", ... } }… 引数の中身。read_fileはpath(読みたいファイルの場所)を文字列で1つ受け取ります。required: ["path"],…pathは必ず要る引数だ、という指定。これがあると、LLMはパスを省略せずに渡してくれます。- 下半分の
list_filesも同じ形で、受け取る引数がpathではなくdir(フォルダの場所)になっただけです。
📝 ここはまだ「メニューに載せただけ」。注文を受けて実際に手を動かす部分は、次の
executeToolで作ります。メニューに料理名を書いても、厨房が作れなければ出てこないのと同じです。
③ 実際に動かす係(executeTool)に中身を足す
LLMが「read_file を使いたい、path は README.md」と言ってきたとき、本当にファイルを読むのがこの関数です。第4章で作った executeTool の switch に、case を2つ足します。
async function executeTool(name: string, input: any): Promise<string> {
switch (name) {
case "read_file":
try {
return await fs.readFile(input.path, "utf-8");
} catch (e) {
return `読み込み失敗: ${(e as Error).message}`;
}
case "list_files":
try {
const names = await fs.readdir(input.dir);
return names.join("\n");
} catch (e) {
return `一覧の取得に失敗: ${(e as Error).message}`;
}
default:
return `不明な道具: ${name}`;
}
}
1行ずつ読むと:
async function executeTool(name: string, input: any): Promise<string> {… 「道具の名前」と「引数」を受け取り、結果を文字列で返す係です。asyncなのは、ファイル読み込みのように「少し待つ」作業をawaitで待つため。返り値が必ず文字列なのは、結果をそのままLLMに渡すから(LLMは文字列を読んで考える)。switch (name) {… 渡された道具の名前で、行き先を振り分けます。case "read_file":… 名前がread_fileのときの処理。さっきメニューに書いたnameと同じ綴りでつないでいます。try {… ここから失敗するかもしれない処理。読み込みは「ファイルが無い」などで失敗しうるので、必ずtry/catchで囲みます。return await fs.readFile(input.path, "utf-8");… この章の主役の1行。input.path(LLMが指定したファイルの場所)を開き、中身を読んで返します。第2引数の"utf-8"は「人が読める文字列として読んで」という指定(これを忘れると、文字ではなく生のデータの塊で返ってきてしまいます)。} catch (e) {… もし読み込みが失敗したら、ここに来ます。return \読み込み失敗: ${(e as Error).message}`;` … 失敗したことを、エラーで止まるのではなく“結果の文字列”として返します。なぜそうするかは後で詳しく説明しますが、ひとことで言うと「LLMに『その道具は失敗したよ』と伝えて、別の手を考えさせる」ためです。case "list_files":… 名前がlist_filesのときの処理。const names = await fs.readdir(input.dir);…input.dir(指定フォルダ)の中にある名前の一覧を、配列として受け取ります(例:["index.ts", "utils.ts", "data"])。return names.join("\n");… 配列のままではLLMに渡しにくいので、join("\n")で1行に1つずつ並べた文字列に変換して返します。\nは「改行」のこと。default:… メニューに無い未知の名前が来たときの保険。第4章から引き継いだ1行です。
これで完成です。新しい関数も、新しいループも要りません。前章のループに、道具の“中身”を2つ足しただけ。これがエージェント開発の気持ちよさで、骨組み(ループ)が一度できれば、能力は道具を足すだけで増えていきます。
デモ:「README を読んで要約して」
道具がつながったか、実際に確かめましょう。agent.ts のあるフォルダに、ためしに README.md を1つ置いてから(中身は数行の説明文で十分)、起動します。
npx tsx agent.ts
1行ずつ読むと:
npx tsx agent.ts…agent.tsをそのまま動かすコマンド(第2章で確立した起動方法)。>の入力待ちが出たら準備OKです。
入力待ちに、こう打ちます。
> README.md を読んで、3行で要約して
すると、エージェントは内部でこんな往復をします(背骨🔁が動いている瞬間です)。
- LLM:「中身を見ないと要約できない。
read_fileを使う、pathはREADME.md」 - あなたのコード(
executeTool):実際にREADME.mdを読み、中身の文字列をLLMに返す - LLM:返ってきた中身を踏まえて「この3行が要約です」と答えて終了
ポイントは、あなたは「読んで」と一度言っただけなのに、エージェントが「読む→中身を踏まえて答える」の2手を自分で踏んでいること。list_files も同じように「src の中に何がある?」と聞けば、フォルダの一覧を自分で取ってから答えてくれます。道具を渡したぶんだけ、エージェントは自分で調べられるようになったわけです。
⚠️ ハマりどころ
-
パスを鵜呑みにすると、作業フォルダの外まで読めてしまう(🔧 伏線) いまの
read_fileは、LLMが言ってきたinput.pathをそのまま開きます。../を重ねたパスを渡されれば、プロジェクトの外(他人の秘密ファイルなど)まで読めてしまう。いまは穴が空いたままで構いません——ただし「ここは後で塞ぐ」と覚えておくこと。封じ込めは第9章でやります。 -
巨大ファイルを読むと、トークンが爆発する(🔧 応用)
read_fileは中身を丸ごと返します。もし数万行のログファイルを読ませると、その中身がまるごとコンテキスト(AIが一度に見ている情報)に積まれ、料金(トークン)も跳ね上がり、上限を超えてエラーになることも。大きなファイルは「最初のN行だけ」「特定の語を含む行だけ」のように読む量を絞る工夫が要ります(コストの考え方は付録F)。いまは小さなファイルで試せばOK。 -
存在しないパスでエージェントを“落とさない”
read_file("nai.txt")のように無いファイルを指定すると、fs.readFileはエラーを投げます。try/catchで囲んでいないと、プログラムそのものが止まってしまう。だから上のコードでは囲んで、「読み込み失敗: …」という“結果”として返すようにしました。こうすると、LLMは「あ、無いのか。じゃあlist_filesで名前を確かめよう」と自分で立て直せます。エラーは「止める理由」ではなく「LLMに渡す手がかり」なのです。 -
メニューと中身で名前がズレる
toolsのname("read_file")と、executeToolのcase "read_file"の綴りが1文字でも違うと、その道具は呼ばれてもdefault(不明な道具)に落ちます。名前は必ず一致させましょう。
🤖 AIに頼むなら(Vibe codingのコツ)
道具を1つ足すこの種の作業は、AIエージェント(Claude Code など)に頼むと速いです。ただし丸投げすると、try/catch を省いたり、パスをそのまま開く危ない版が出てきがち。何を守ってほしいかを明示して頼みましょう。
🗣 プロンプト例: 「いま
tools配列とexecuteTool関数があるTypeScriptのCLIエージェントに、read_fileとlist_filesの2つの道具を足してほしい。条件は4つ:(1)fs/promisesを使う。(2)read_fileはpathを、list_filesはdirを引数に取る形で、toolsの定義とexecuteToolのcaseの両方を、同じ名前で足す。(3) ファイル読み込みやフォルダ一覧はtry/catchで囲み、失敗時は例外を投げずに“失敗した旨の文字列”を返す(LLMに結果として渡したいので)。(4) いまはパスの検証(作業フォルダの外を弾く処理)は入れなくていい——それは後の章でまとめてやる、と分かるようにコメントだけ残して。」
出てきたコードを見るときの確認ポイント:
toolsのnameとexecuteToolのcaseの綴りが一致しているか?- ファイル操作が
try/catchで囲まれ、失敗を“結果の文字列”として返しているか?(エラーで落ちない) - パスの検証は「後章でやる」前提になっているか?(いまここで中途半端な検証を足して、かえって分かりにくくしていないか)
- 大きすぎる出力に注意——丸ごと読む実装であることを理解しているか?(巨大ファイルでトークンが膨らむ)
read_fileで"utf-8"を渡し、人が読める文字列で返しているか?
📝 ことばメモ
fs/promises(えふえす・プロミセズ):Node.js 標準の「ファイル操作」機能の、async/awaitで待てる版。fs.readFile(読む)・fs.readdir(フォルダの中身を一覧)などが入っている。node:fs/promisesと書いて読み込む(インストール不要)read_file(りーど・ふぁいる):この章で作った道具。path(ファイルの場所)を受け取り、await fs.readFile(path, "utf-8")で中身を文字列にして返す。読み取りなので比較的安全list_files(りすと・ふぁいるず):この章で作った道具。dir(フォルダの場所)を受け取り、await fs.readdir(dir)で中の名前の一覧を取り、join("\n")で1行ずつの文字列にして返す- パス封じ込め(伏線):道具が触れる範囲を「作業フォルダの中だけ」に制限する考え方。
../で外へ出るのを防ぐ。いまの素朴な実装にはまだ無い=穴が空いた状態。第9章でしっかり塞ぐ - 「エラーを結果として返す」:道具が失敗しても例外で止めず、「失敗した」という文字列をLLMに渡す作法。LLMが状況を理解して別の手を考えられるようになる
➡️ 次の章へ
おめでとうございます。エージェントが自分でプロジェクトを“読んで調べる”ようになりました。でも、できるのはまだ「見る」だけ。バグを見つけても、自分では直せません。
次の第6章では、いよいよ「書く」道具(write_file)を作ります。読めるだけだったエージェントが、ファイルを書き換えられるようになる——とても便利です。が、ここで初めて「上書き=取り返しがつかないかも」という、本物の危なさが顔を出します。本書で「危ない道具」が登場するのは、ここからです。