自作コーディングエージェントのコンテキストエンジニアリング
TL;DR
- コンテキストエンジニアリングとは、検索である
- コンテキストは、読み書きできるテキストファイルにする必要がある
- コンテキストファイルは、絶対パスまたは相対パスで検索することになる
Intro
少し前にShaftというCLI型コーディングエージェントを作りました。
必要最低限の機能を実装することで、Claude CodeやGemini CLIが解決できなかったコンテキストエンジニアリングの問題を根本から解決することができましたが、結局何が正しかったのか、よく分かっていません。
そこでバズワードと化したコンテキストエンジニアリングを今一度整理して、Shaftに実装したであろうコンテキストエンジニアリングに関する機能を紹介しつつ、こうすればうまくいくだろうというベストプラクティスを紹介していきたいと思います。
Context Engineering
Shaft開発前からコンテキストエンジニアリングについて把握していましたが、各々がポジショントークで拡張していくひどいものでした。
MCPとA2A、AIエージェントとコーディングエージェントもそんな感じですね。
時系列から、コンテキストエンジニアリングは大体以下の4つに分かれると思われます。
- 2025-06-12 - Devinを開発したCognition AIのWalden Yan-sanが提唱したコンテキストエンジニアリングの原則
- 2025-06-12 - 12-Factor Agentsで有名なDex Horthy-sanが作成したコンテキストエンジニアリングの図
- 2025-06-23 - LangChainのLance Martin-sanが紹介したコンテキストエンジニアリングの分類
- 2025-06-30 - Google DeepMindのPhilipp Schmid-sanが作成したコンテキストエンジニアリングの図
とりあえず最初に具体的な2と4について実現できているか確認した後、抽象的な1と3についてどのぐらい実現できているか確認していきたいと思います。
-
Instructions / System Prompt
- User Prompt
-
State / History (short-term Memory)
- Long-Term Memory
-
Retrieved Information (RAG)
-
Structured Output
- Available Tools
-
Multi Agent / Agent / Subagent
- Task / Subtask / Result
-
Write Context
-
Select Context
-
Compress Context
-
Isolate Context
Instructions / System Prompt / User Prompt
さて、そもそもShaft(と前身のGitruck)開発のモチベーションは「どのコンテキストファイルが読み込まれているか把握できない」ところにありました。
また、何らかのシステムプロンプト、ユーザープロンプトを送信していることも分かりますが、簡単にコードリーディングできるようなものでもありませんでした。
Gemini CLIについては最近PacketProxyでパケットをキャプチャする手法が紹介されたので、これで大体把握することができました。
そもそも論、コードリーディングをしたり、パケットをキャプチャしないといけないコーディングエージェントは仕組みとしておかしいのではないか? というのが最初の持論です。
そこでShaftではシステムプロンプト、ユーザープロンプトすべてをテンプレートにすることで、間にハードコーディングされたプロンプトを一切差し込めない仕組みを実装することにしました。
Templates
テンプレートファイルにはJSONとJinja2の組み合わせを採用することにしました。
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Shaft Task Persona Template Schema",
"description": "Schema for persona-based template files for the `task` command in shaft.",
"type": "object",
"properties": {
"system_content": {
"type": "string",
"description": "The system prompt or instructions for the language model."
},
"user_content": {
"type": "array",
"description": "A list of strings that will be joined to form the user prompt. Can include Jinja2 templating.",
"items": {
"type": "string"
}
},
"json_schema": {
"type": "object",
"description": "A JSON schema that defines the expected output format from the language model. Its structure is flexible."
},
"default_file_path": {
"type": [
"string",
"array"
],
"description": "A default file or list of files to be used as context if none are provided.",
"items": {
"type": "string"
}
}
},
"required": [
"user_content"
]
}
なぜこんなへんてこな組み合わせにしたかというと、以下のような理由があります。
- 後述するStructured Outputで使用するJSON Schemaを直接テンプレートに埋め込めるようにしたいから
- テンプレートそのものもJSON Schemaで定義できる
- Jinja2でPythonのコンテキストやフィルターを注入できる
- JSON以外のテンプレートを注入できる(Jinja2マクロ、テキストファイルなど)
- カスタムテンプレートを格納するディレクトリをカレントディレクトリやホームディレクトリに用意できる
- PythonソースコードやMarkdownコンテキストファイルと異なる拡張子を採用することで、globやrgコマンドで分割統治できる
- Frontmatterを採用すると、YAMLとTOMLのサポートをしなければならないから
例えば以下の要件定義書を作成するシステムアーキテクトのテンプレートを見てみましょう。
{
"system_content": "You are a senior systems architect. Your goal is to provide a precise and actionable JSON array of text changes.",
"user_content": [
"Each JSON object in the array must represent a single, atomic change to a file and contain the following keys:",
"{% for key, desc in property_descriptions.items() %}",
"- `{{ key }}`: {{ desc }}",
"{% endfor %}",
"",
"Ensure that 'search_block' and 'replace_block' are exact matches of the text, including all indentation and newlines.",
"",
"---",
"Rules:",
"- Create or modify a requirements definition document.",
"- Ensure all the following points are covered.",
"- Identification of requirements and definition of constraints.",
"- Definition of business requirements.",
"- Specification of organizational and environmental requirements.",
"- Definition of functional and non-functional requirements.",
"- Definition of scheduling requirements.",
"",
"---",
"Date: {{ datetime }}",
"",
"---",
"Platform: {{ platform }}",
"",
"---",
"Current Working Directory: {{ cwd }}",
"",
"---",
"Task Description: {{ task_description }}",
"",
"---",
"Original File Contents:",
"{% for file_path, content in target_contents.items() %}",
"**File: {{ file_path | relative_path }}**",
"```",
"{{ content }}",
"```",
"",
"{% endfor %}",
"",
"---",
"Additional Context:",
"{% for file_path, content in context_contents.items() %}",
"**File: {{ file_path | relative_path }}**",
"```",
"{{ content }}",
"```",
"",
"{% endfor %}",
"",
"---",
"Now, generate the JSON array with the proposed changes."
],
"json_schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The path to the file that needs modification. This path must be one of the files listed in 'Original File Contents'."
},
"search_block": {
"type": "string",
"description": "The exact, verbatim block of original text to be replaced. This must be a literal copy from the current file content, including all whitespace, indentation, and newlines. It should uniquely identify the text to be replaced."
},
"replace_block": {
"type": "string",
"description": "The new block of text to replace the search_block with. This should be the complete, new text, including all necessary whitespace, indentation, and newlines."
},
"explanation": {
"type": "string",
"description": "(Optional) A detailed explanation of why this change is being made, formatted in Markdown. This explanation should cover the problem, the solution, and any important considerations."
}
},
"required": [
"file_path",
"search_block",
"replace_block"
]
}
},
"default_file_path": [
"./.shaft/shaft/requirements.md"
]
}
ファイルを見るだけでこのファイルが何をするか一目瞭然ですね!
ちなみにShaftではJSONファイルで定義された指示のことを「ペルソナ」を呼んでいます。
詳しくは以下の記事を参照してください。
Context File
テンプレートファイルを見れば分かる通り、システムプロンプトではペルソナとゴール、ユーザープロンプトには入力内容をだけではなく、ルールやコンテンツ内容を含めることができます。
コンテンツ内容は主に大きく以下の内容に分けることができます
- 対象ファイル(
-f
,--file
オプションフラグなど) - コンテキストファイル
また、コンテキストファイルは以下の内容を含みます。
- 自動的に参照されるコンテキストファイル(
SHAFT.md
など) - 手動で指定する参照ファイル(
-r
,--ref
オプションフラグなど)
これらのファイルは複数指定可能で、ファイル指定方法として様々な方法を提供しています。
- 1つごとオプションフラグでファイルを指定
- 例:
-f src/main.py -f src/util.py
- 例:
- ディレクトリ内のファイルすべて指定
- 例:
-f src
- 例:
- globで指定
- 例:
-f src/**/*.py
- 例:
- ファイルリストで指定
- 例:
-F filelist.txt
- findやgrepで出力した内容をfilelist.txtに書き込んで呼び出せる
- 例:
3と4はClaude CodeやGemini CLIではAvailable Toolsを使わなければ実現できませんし、Available Toolsを何回も呼び出す処理が行わると、その分API呼び出しコストがかかります。
これらの作業を手作業で明示的に行えるようにすることで、API呼び出し回数を分かりやすくし、意図しないファイル指定を制限することができるようになっています。
ファイルパスに関する操作をLLMで自動化しないということは、それだけ手作業が増えますから、いかに対象ファイル含めたコンテキストファイルを指定する負担を減らすかに注力して機能を実装していくように工夫しました。
他にもshaft output ls
にオプションフラグを追加したりして、ファイルパスそのもののコンテキストを増やせるようにしています。
$ shaft output ls -d -C
40 src
40 src/shaft
14 src/shaft/templates
12 tmp
8 src/shaft/core
8 src/shaft/utils
7 docs
7 src/shaft/templates/ask
7 src/shaft/templates/task
4 src/shaft/services
4 src/shaft/core/__pycache__
4 src/shaft/utils/__pycache__
3 docs/static
3 src/shaft/__pycache__
2 docs/en
2 docs/ja
2 src/shaft/services/__pycache__
2 tmp/spec
$ shaft output ls -d -C > .shaft/shaft/count.txt
Absolute Path / Relative Path
Claude CodeやGemini CLIの開発体験の悪さの一つとして、編集対象のファイルパスを正常に参照できない点があります。
特にGemini CLIはtree形式でユーザープロンプトに埋め込んでいるらしいので(DeNA EngineeringのPacketProxy記事より)、LLMに優しくありません。
そこでShaftでは絶対パスと相対パスに関する処理を徹底することにしました。
shaft task
コマンドでは、主に以下の手順で処理を行っています。
- オプションフラグで渡されたパスをすべて絶対パスに変換
- この時
os.path.expanduser()
やos.path.expandvars()
の処理を行っているため、WindowsでもLinuxと同じようにホームディレクトリの解決や環境変数の展開を行うことができます -
os.path.abspath()
内部でos.path.normpath()
の処理も行っているため、余分な区切り文字や上位レベル参照も除去しています
- この時
- ユーザープロンプト内部でカレントディレクトリ以下のファイルは相対パスに再度変換
- 一度絶対パスに変換してあるため、
./
や../
といったLLMが誤って解釈してしまう相対パスの参照を除去することができます - カレントディレクトリ以下にないファイル(
~/.shaft/SHAFT.md
など)は絶対パスのままユーザープロンプトに入力されます
- 一度絶対パスに変換してあるため、
- Structured OutputでLLMが出力した
file_path
が対象ファイルのファイルパスと一致しているか確認-
shaft task
コマンドの場合、対象ファイルではないファイルをLLMが編集できてしまうと大変なことになるので、もしfile_path
が相対パスの場合は再度絶対パスに変換し、絶対パスと完全一致するか確認します - ただ完全一致する文字列はWindowsの場合、大文字と小文字を区別しなくていいので、
os.path.normcase()
で変換しています
-
Structured Output
ShaftはAvailable Toolsを使う必要性が今のところないため、これらの入力を踏まえて、構造化出力された文字列をもとに処理を行っています。
先程のシステムアーキテクトのJSON Schemaを見てみましょう。
{
"type": "array",
"items": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The path to the file that needs modification. This path must be one of the files listed in 'Original File Contents'."
},
"search_block": {
"type": "string",
"description": "The exact, verbatim block of original text to be replaced. This must be a literal copy from the current file content, including all whitespace, indentation, and newlines. It should uniquely identify the text to be replaced."
},
"replace_block": {
"type": "string",
"description": "The new block of text to replace the search_block with. This should be the complete, new text, including all necessary whitespace, indentation, and newlines."
},
"explanation": {
"type": "string",
"description": "(Optional) A detailed explanation of why this change is being made, formatted in Markdown. This explanation should cover the problem, the solution, and any important considerations."
}
},
"required": [
"file_path",
"search_block",
"replace_block"
]
}
}
詳しいコード編集ロジックについては前身のGitruckで書いていますので参照してください。
ただ全てのモデルがStructured Output機能を使えるわけではないため、Structured Output機能が使えないモデルはユーザープロンプトを確認しjson_array
で構造化出力できるようフォールバック処理を行っています。
Not implemented
以上のことから、現時点でShaftはInstructions / System Prompt / User PromptとStructured Outputしか実装していないことが分かります。
他のコンテキストエンジニアリングについて、現時点での見解を述べたいと思います。
State / History / Memory
これはなるべく早めに実装予定です。
LiteLLMはOpenAI APIと同様にAssistant Roleがあるため、そこに構造化出力された内容を加工して入力し、SystemとUserのroleに過去のシステムプロンプト、ユーザープロンプトを入力すればよさそうです。
ただシステムプロンプト、ユーザープロンプト、構造化出力をどのようなフォーマットで履歴として残しておくのか、もし残しておくのであればどのような情報を残しておくのか、履歴管理はどのようにするのかちゃんと考えて実装する必要がありそうなので後回しにしています。
変に実装してしまうとClaude Codeのようにアホになってしまいますからね。
あとMemoryをTermで分けるのはナンセンスだと思っていて、例えば-n
, --number
オプションフラグで過去n回分の履歴を参照するとか、ユーザーがある程度コントロールできるようにしたほうがいいと考えています。
Retrieved Information (RAG)
例えばWeb検索であればGoogle一強でどうやっても勝てないですし、そういった最新情報は.shaft/shaft
ディレクトリにテキストファイルとして置いておいてくれというのがShaftの設計思想です。
ただせっかくJSON Schemaに注力しているのでMCPクライアントを実装して取得できるようにはするかもしれないです。
Available Tools
これいる?
何かAIエージェントやコーディングエージェントはAvailable Toolsを実装して自律的に動いた時点でゴールみたいな感じがありますが、Structured Outputで何とかなってしまっているので……。
何にせよAvailable Toolsが何回も失敗する問題や呼び出す度にコストがかかる問題が解決してから考えたいと思います。
Multi Agent / Isolate Context
最後に、コンテキストエンジニアリングの抽象的な内容はどのぐらい実現できているか確認していきましょう。
Shaftにはペルソナという機能があり、例えばシステムアナリストが生成した企業活動や経営戦略に関するコンテキストファイルを共有して、システムアーキテクトが要件定義や設計を作成することができます。
したがって、ペルソナが生成するコンテキストファイルをどのように他のペルソナ(エージェント)やタスクに渡すかが今後重要になってくる気がします。
あまり大量にコンテキストファイルを渡してしまうと混乱してしまいますからね。
現時点ではコンテキストファイルの読み込み設計があまりうまくいっていないので、もっとうまくできるようにしたいです。
Compress Context
これが結構難しくて、どのコーディングエージェントもコンテキスト圧縮がLLM頼りで不確実性が強く、モデルの性能に左右されるように感じています。(Claude Codeがアホになるやつとか)
ではもっと手続き通りできるようにしましょうという話になるのですが、そのためにはLSPを解析する必要があって、ありとあらゆるテキストファイルのLSPを解析するのめっちゃしんどいなってなります。
AiderなんかはTree-sitterを使ったりしていますが……。
こういう複雑な処理はSerena MCPみたいなMCPに丸投げするのが一番かもしれないですね。
Select Context / Write Context
これはもう解説しましたね。コンテキストの選択、書き込みは奥が深いので、まだまだ改善の余地がありそうです。
Outro
以上、自作コーディングエージェントのコンテキストエンジニアリングについて紹介してみました。
コーディングエージェントを自作するとClaude CodeやGemini CLIを使う上でもコンテキストエンジニアリングの問題に気づき、こうやって言語化することができるようになるので、皆さんもコーディングエージェントの自作に挑戦してみてはいかがでしょうか。
自作に挑戦しなくてもShaftを使えばコンテキストエンジニアリングについて学びがあるかもしれないので、Shaftを使ってみるのもいいかもしれません!(宣伝)
Ref
Discussion