🚗

自作コーディングエージェントのコンテキストエンジニアリング

に公開

TL;DR

  1. コンテキストエンジニアリングとは、検索である
  2. コンテキストは、読み書きできるテキストファイルにする必要がある
  3. コンテキストファイルは、絶対パスまたは相対パスで検索することになる

Intro

少し前にShaftというCLI型コーディングエージェントを作りました。

https://gitlab.com/tkithrta/shaft

必要最低限の機能を実装することで、Claude CodeやGemini CLIが解決できなかったコンテキストエンジニアリングの問題を根本から解決することができましたが、結局何が正しかったのか、よく分かっていません。

そこでバズワードと化したコンテキストエンジニアリングを今一度整理して、Shaftに実装したであろうコンテキストエンジニアリングに関する機能を紹介しつつ、こうすればうまくいくだろうというベストプラクティスを紹介していきたいと思います。

Context Engineering

Shaft開発前からコンテキストエンジニアリングについて把握していましたが、各々がポジショントークで拡張していくひどいものでした。
MCPとA2A、AIエージェントとコーディングエージェントもそんな感じですね。

時系列から、コンテキストエンジニアリングは大体以下の4つに分かれると思われます。

  1. 2025-06-12 - Devinを開発したCognition AIのWalden Yan-sanが提唱したコンテキストエンジニアリングの原則
  2. 2025-06-12 - 12-Factor Agentsで有名なDex Horthy-sanが作成したコンテキストエンジニアリングの図
  3. 2025-06-23 - LangChainのLance Martin-sanが紹介したコンテキストエンジニアリングの分類
  4. 2025-06-30 - Google DeepMindのPhilipp Schmid-sanが作成したコンテキストエンジニアリングの図

https://cognition.ai/blog/dont-build-multi-agents
https://www.promptingguide.ai/guides/context-engineering-guide
https://rlancemartin.github.io/2025/06/23/context_engineering/
https://www.philschmid.de/context-engineering

とりあえず最初に具体的な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でパケットをキャプチャする手法が紹介されたので、これで大体把握することができました。

https://engineering.dena.com/blog/2025/07/gemini-cli-packetproxy-context-engineering/

そもそも論、コードリーディングをしたり、パケットをキャプチャしないといけないコーディングエージェントは仕組みとしておかしいのではないか? というのが最初の持論です。

そこでShaftではシステムプロンプト、ユーザープロンプトすべてをテンプレートにすることで、間にハードコーディングされたプロンプトを一切差し込めない仕組みを実装することにしました。

Templates

テンプレートファイルにはJSONとJinja2の組み合わせを採用することにしました。

task.schema.json
{
  "$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のサポートをしなければならないから

例えば以下の要件定義書を作成するシステムアーキテクトのテンプレートを見てみましょう。

systems_architect-1.json
{
  "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ファイルで定義された指示のことを「ペルソナ」を呼んでいます。
詳しくは以下の記事を参照してください。

https://zenn.dev/tkithrta/articles/922a4f787351a7

Context File

テンプレートファイルを見れば分かる通り、システムプロンプトではペルソナとゴール、ユーザープロンプトには入力内容をだけではなく、ルールやコンテンツ内容を含めることができます。
コンテンツ内容は主に大きく以下の内容に分けることができます

  • 対象ファイル(-f, --fileオプションフラグなど)
  • コンテキストファイル

また、コンテキストファイルは以下の内容を含みます。

  • 自動的に参照されるコンテキストファイル(SHAFT.mdなど)
  • 手動で指定する参照ファイル(-r, --refオプションフラグなど)

これらのファイルは複数指定可能で、ファイル指定方法として様々な方法を提供しています。

  1. 1つごとオプションフラグでファイルを指定
    • 例:-f src/main.py -f src/util.py
  2. ディレクトリ内のファイルすべて指定
    • 例:-f src
  3. globで指定
    • 例:-f src/**/*.py
  4. ファイルリストで指定
    • 例:-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コマンドでは、主に以下の手順で処理を行っています。

  1. オプションフラグで渡されたパスをすべて絶対パスに変換
    1. この時os.path.expanduser()os.path.expandvars()の処理を行っているため、WindowsでもLinuxと同じようにホームディレクトリの解決や環境変数の展開を行うことができます
    2. os.path.abspath()内部でos.path.normpath()の処理も行っているため、余分な区切り文字や上位レベル参照も除去しています
  2. ユーザープロンプト内部でカレントディレクトリ以下のファイルは相対パスに再度変換
    1. 一度絶対パスに変換してあるため、./../といったLLMが誤って解釈してしまう相対パスの参照を除去することができます
    2. カレントディレクトリ以下にないファイル(~/.shaft/SHAFT.mdなど)は絶対パスのままユーザープロンプトに入力されます
  3. Structured OutputでLLMが出力したfile_pathが対象ファイルのファイルパスと一致しているか確認
    1. shaft taskコマンドの場合、対象ファイルではないファイルをLLMが編集できてしまうと大変なことになるので、もしfile_pathが相対パスの場合は再度絶対パスに変換し、絶対パスと完全一致するか確認します
    2. ただ完全一致する文字列はWindowsの場合、大文字と小文字を区別しなくていいので、os.path.normcase()で変換しています

https://learn.microsoft.com/ja-jp/windows/wsl/case-sensitivity

Structured Output

ShaftはAvailable Toolsを使う必要性が今のところないため、これらの入力を踏まえて、構造化出力された文字列をもとに処理を行っています。

先程のシステムアーキテクトのJSON Schemaを見てみましょう。

systems_architect-1.json
{
  "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で書いていますので参照してください。

https://zenn.dev/tkithrta/articles/d3fa29b400c2e9

ただ全てのモデルが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に丸投げするのが一番かもしれないですね。

https://github.com/oraios/serena
https://blog.lai.so/serena/

Select Context / Write Context

これはもう解説しましたね。コンテキストの選択、書き込みは奥が深いので、まだまだ改善の余地がありそうです。

Outro

以上、自作コーディングエージェントのコンテキストエンジニアリングについて紹介してみました。
コーディングエージェントを自作するとClaude CodeやGemini CLIを使う上でもコンテキストエンジニアリングの問題に気づき、こうやって言語化することができるようになるので、皆さんもコーディングエージェントの自作に挑戦してみてはいかがでしょうか。

自作に挑戦しなくてもShaftを使えばコンテキストエンジニアリングについて学びがあるかもしれないので、Shaftを使ってみるのもいいかもしれません!(宣伝)

https://gitlab.com/tkithrta/shaft/-/blob/main/docs/ja/getting_started.md
https://gitlab.com/tkithrta/shaft/-/blob/main/docs/ja/user_guide.md

Ref

https://zenn.dev/tkithrta/articles/9a6f1f2d249790
https://zenn.dev/kun432/scraps/d462287d9dbfa9
https://speakerdeck.com/schroneko/context-engineering
https://speakerdeck.com/watany/ai-no-me-mo-ri
https://blog.lai.so/kiro-in-context-engineering/
https://zenn.dev/mizchi/articles/claude-code-singularity-point

Discussion