ぼくのかんがえたさいきょうのコーディングエージェント(予定)、pactを作っている

tl;dr

AIシバいて作ってます: https://github.com/k16em/pact
READMEもdocsもないことからわかるように、完全に自分用として進めている

archlinux + local llm or ChatGPT Subscriptionでしか動作確認してないよ〜

なんで?

AIに自由を与えない をいいかんじに実現できなかったので。ブチギレ駆動です。

コア機能

ユーザ定義のtool_call

コーディングエージェントを使っていて一番イラついているのは、

  • 単一のコマンドで十分なのになんでもpythonで解決しようとする
  • 指定したコマンドをうまく使えないとなにかしら別の手段を使ってバイパスしようとする

というところです。たとえばこんなん。



これは、だいたいのコーディングエージェントがAIを自由にさせすぎているから(= bash や sh を与えているから)です。自由にさせたほうが楽で早いのはそうなんですが、これでなにか他人のサービスに迷惑をかけたり問題が起きたときに、「いやAIがやったんで……」とは言いたくないし、言ったとて許されないのが目に見えています。
自分の考え方としても、やってほしくないことはそもそもできないべきというものがあります。本当に厳密にやるのであれば、MACやFirewallによる制御が必要なわけですが、んなことしてたら通常の作業に影響が出るに決まっています。

そういうわけで、現実的な落としどころとして、基本的な読み書きを行うためのbuilt-in tool、ユーザ定義tool、toolの組み合わせで処理フローを定義するtoolchainを用意しました。AIへのリクエストに含まれる tools ブロックの選択肢はこれらに制限されます。

built-in toolは、list, read, create, delete, edit, moveの6種類のみ用意しています。これらはpact自身に組み込まれた特別なツールで、一部特別な処理(list, readはhookを送出しない、editは実行後にhookを経由せずdiffが表示される、など)が実装されています。が、AIからの視点では他のtoolと変わりません。

ユーザ定義ツールはユーザ自身がtoolを定義するものです。たとえば以下のように定義します。

[[tool]]
name = "git-log"
description = "Read full git log."
command = ["git", "log"]

[[tool]]
name = "git-diff"
description = "show git diff"
command = ["git", "diff"]

[[tool]]
name = "git-status"
description = "show current git status"
command = ["git", "status"]


[[tool]]
name = "git-show"
description = "show single commit information."
command = ["git", "show", { input = "hash" }]

[[tool.input]]
name = "hash"
type = "string"
description = "commit hash. you can get by git-log tool. default: HEAD"

このように定義したツールは、AIへのリクエストのtoolブロックにて、以下のように公開されます。

{
      "function": {
        "description": "Read full git log.",
        "name": "git-log",
        "parameters": {
          "additionalProperties": false,
          "properties": {},
          "required": [],
          "type": "object"
        },
        "strict": true
      },
      "type": "function"
    },
    {
      "function": {
        "description": "show git diff",
        "name": "git-diff",
        "parameters": {
          "additionalProperties": false,
          "properties": {},
          "required": [],
          "type": "object"
        },
        "strict": true
      },
      "type": "function"
    },
    {
      "function": {
        "description": "show current git status",
        "name": "git-status",
        "parameters": {
          "additionalProperties": false,
          "properties": {},
          "required": [],
          "type": "object"
        },
        "strict": true
      },
      "type": "function"
    },
    {
      "function": {
        "description": "show single commit information.",
        "name": "git-show",
        "parameters": {
          "additionalProperties": false,
          "properties": {
            "hash": {
              "description": "commit hash. you can get by git-log tool. default: HEAD",
              "type": [
                "string",
                "null"
              ]
            }
          },
          "required": [
            "hash"
          ],
          "type": "object"
        },
        "strict": true
      },
      "type": "function"
    },

コマンド自体は公開せず、toolの使い方とtoolへ渡すべき値のみが公開されます。

また、連続した一連の流れを表現したいこともあります。そうしたときには、toolchainを使います。toolchainは以下のように定義します。

[[tool]]
name = "git_commit_1_add"
description = "Stage selected files for a commit."
callable = "chain_only"
command = ["git", "add", "--", { input = "paths" }]

[[tool.input]]
name = "paths"
type = "array"
items = "string"
description = "Files to stage."
min_items = 1
required = true
path = true

[[tool]]
name = "git_commit_2_commit"
description = "Commit staged changes."
callable = "chain_only"
command = ["git", "commit", "-m", { input = "message" }]

[[tool.input]]
name = "message"
type = "string"
description = "Commit message."
required = true

[[tool_call_chain]]
name = "git_commit"
description = "Stage selected files and commit the changes."
tools = ["git_commit_1_add", "git_commit_2_commit"]

toolchainで定義したtoolは、まず以下のように [[tool_call_chain]] のものだけが常に渡されます。

    {
      "function": {
        "description": "Stage selected files and commit the changes. (chain: git_commit_1_add -> git_commit_2_commit; arguments are for git_commit_1_add)",
        "name": "git_commit",
        "parameters": {
          "additionalProperties": false,
          "properties": {
            "paths": {
              "description": "Files to stage. (file or directory path)",
              "items": {
                "type": "string"
              },
              "minItems": 1,
              "type": "array"
            }
          },
          "required": [
            "paths"
          ],
          "type": "object"
        },
        "strict": true
      },
      "type": "function"
    },

[[tool_call_chain]] で定義されたtoolをcallすると、toolchainモードに入り、次のリクエストから[[tool_call_chain]] 内で指定されたtoolのみが公開されます。git_commitの例で行くと、

  1. git_commit をcallする
  2. toolchainモードに入る
  3. git_commit_1_addのみがAIに公開される
  4. git_commit_2_commitのみがAIに公開される
  5. toolchainモードを解除

という流れです。途中のコマンドが失敗してもtoolchainは終了します。

また、toolのparamsには path という属性を持たせています。これは、その入力値がファイルパスであることを示しますが、AIへの補助のためではなく、ファイルアクセスの簡易的な制御のためです。

tool定義ファイルには deny_path をglob形式(GitHub Actionsのpathのアレ)で定義することができます。path属性を持つparamに入力されたファイルパスが、deny_pathで指定した値、.gitignoreに記述されている値、pact組み込みの値のいずれかにマッチした場合、ツール実行が即座拒否されます。

同様の処理として、toolの実行結果もAIに返す前に正規表現でフィルターし、ある程度見せる値を制限できるようにしています。

hooks

各種toolを呼び出したあと、pactはcreate, edit, delete, moveのhookを実行します。テスト、フォーマッタ、リンターなどの実行を機械的に実行するためのものです。Red-GreenスタイルのTDDとかにいいんじゃないですかね(適当)。

hookは以下のように定義できます。

[[hook]]
name = "run unit test"
events = ["edit", "delete", "move"]
path_pattern = "**/*.go"
commands = [["go", "test", "./..."], ["go", "vet", "./..."]]

[[hook]]
name = "format code"
events = ["edit"]
path_pattern = "**/*.go"
commands = [["go", "fmt", "./..."]]

実行結果はAIに渡るので、それを見ていい感じにコードの修正とかしてくれるはず。

LSP

簡単なLSPサポートを実装しています。

[[lsp]]
name = "gopls"
command = ["gopls"]
extensions = [".go"]
language_id = "go"

LSPを定義していると、lsp_definition, lsp_references, lsp_hover, lsp_symbols, lsp_diagnosticsがtoolとして公開されます。

設定

XDG_CONFIG_HOME/pact/**/* + カレントディレクトリの .pact/**/* + .agents/AGENTS.md をマージするようにしているので、どこでも使う設定はXDG_CONFIG_HOME に、リポジトリ固有の設定はリポジトリに分割して配置できるようにしてます。

現状と今後

現状

ある程度の作業をpactで実行するようになってきている。OpenCodeやCodexの実行時間は減った。

とはいえ、基本ローカルLLMで実行する想定で作っているので、たとえばUsage Limitとかを検知できなかったり、最大コンテキスト長をモデルから取得する処理がなかったりでそのへんいまいちだな〜と思っている。

今後

作りたい機能/やらねばリスト

  • OpenTelemetryへのもろもろ送出
  • セキュリティまわり(いまはローカルに投げる前提なので通信周りてきとう)
  • hookのchain
  • tool_call_chainの実装しなおし(もうちょっと機械的にやりたい)
  • カスタムhookイベント
  • pact自身の使い方をAIに送る
  • env, secretsのtool単位での注入
  • ドキュメント書く
  • workflow

絶対作らないぞ機能リスト

  • Skills対応
  • MCP対応
  • shell対応(現状でも自分の足を撃ち抜く権利としてユーザ定義で bash -c するのは許容してる。 -c で渡すコマンドへのフィルターがどうこうとか言われてもやらない)
  • tool作成をpact内でAIにやらせる

どうすっかな機能リスト

  • remote的な機能
  • question/ask tool
  • sub-agent
  • profile
  • tool提案 or ビルダー

コンセプトとか

  • pactはコーディングエージェントである
    • コードを記述することに特化する
    • 調査とかドキュメント作成とかは別にヘタクソでいい
  • ユーザをナメない、おせっかいしない
    • ユーザは、コードやドキュメントを読んで森羅万象を理解できるので、ツール側でデフォルト設定や説明的な機能をなるべく用意しない
    • ユーザは、必要なものや制限はすべてユーザ自身で設定できる
  • 自然言語による定義は同じように動くことが検証できないので共有資産としては信用しない
    • SkillやAGENTS.mdで動作を制御せず、toolの責務として制限する
    • だいたいみんな他人のSkillそのまま使わずコピペしてチューニングして使ってるでしょ

なんでpactって名前なの?

  • 特定のツールを使ってねという制限を記述するところが契約っぽいなと思ったので
  • なんかいろいろMtGの契約サイクルっぽいなと思ったので
    • どこが?
      • とりあえず自由にtool callさせたあとでhookで確認・修正を強いるところが、0マナでキャストできてあとからコスト請求するところと似ている
      • とりあえず作り始めて動かしてみたけどなんかいっぱい気になるところが出てきたところが、0マナで唱えられるけどあとでコスト払えないと負けるところと似ている
    • AIで加速して理解負債を放置して苦しくなるのって契約死っぽいから、そうならないためにユーザがわかっているツールでワークフローを組むというのもまたそれっぽい気がする

契約サイクルだと 否定の契約/Pact of Negation と 殺戮の契約/Slaughter Pact が好きです。

おしまい