1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Elixir/PhoenixでAIエージェントハーネスを作る:MrEric育成日誌(1)

1
Posted at

はじめに

  • リポジトリ

  • 対象読者: ElixirでAIエージェント基盤を作ろうとしている方、ローカルLLMとクラウドLLMを協調させたい方

Elixir/Phoenix/LiveViewで、AIエージェントハーネスを作っています。

きっかけは2つあります。

  1. AIエージェントを使っているうちに、「もっとこう動いてほしい」とか「この動作が見えないのはなんとなく気持ち悪い」と感じることが増えてきました。やっていることは何となくわかっていても、やはり自分で作った方がもっと原理がわかって納得できます。単純に面白そう、というのもありました。

  2. ローカルLLMを有効活用したいと思っていたから。
    LLMには得意不得意がありますが、それぞれ得意分野を活かして使えないかなという発想が以前からありました。
    特にクラウドLLMと違って、小さいのでいろいろ限界があります。
    以前、GeminiやClaudeをお互いに呼び出し合うようなプロンプトを書いて、上手く連携が取れていたこともありました。
    それならば、ローカルLLMもお互いにつなげてしまえばいいのでは?と思ったのです。

名前は MrEric です。
執事的な役割を果たしてほしいという想いからです。

もともとは、OpenAI APIを呼んでタスクを処理する小さなプロトタイプでした。
そこから少しずつ、

  • OpenAI / OpenRouter / Grok / Ollama / LM Studio などを扱えるLLM抽象層
  • 複数モデルの協調実行
  • Phoenix.PubSubによるリアルタイム進捗表示
  • ツール実行の承認ゲート
  • patch提案と承認後の適用
  • Fake providerによる外部APIなしの評価基盤
  • secretの最低衛生ライン整備(Spec A)
  • Run ownership と approval lifecycle(Spec B)

まで実装しました。

この記事では、Phase 1〜6、Phase 9、そしてその後に入れたSpec A / Spec B までの実装方針をまとめます。

Phase番号が少し飛んでいますが、これは意図的です。
Phase 7の高度なRAG拡張、Phase 8の本格MCP連携、Phase 10のユーザー認証/APIキー管理よりも先に、まずは 安全性と評価基盤 を固める方針にしました。

Phase 9 のあとにも、必ず塞いでおきたい穴があったので、それを Spec A(secret hygiene)Spec B(run ownership / approval lifecycle) という形で先に入れています。


作っているもの

MrEricは、ざっくり言うと 複数のLLMを束ねるAIエージェント実行環境 です。

ユーザーがタスクを入力すると、内部では次のような流れで処理します。

さらに、必要に応じてツールを使います。

ここで大事にしたのは、AIが勝手に危険な操作をしないことです。

ファイル操作、shell実行、patch適用などは、原則として承認制にしています。
さらにSpec Bで、承認は session の owner にしか出せず30分で自動的に期限切れする ようにしました。


なぜElixirで作るのか

AIエージェントハーネスは、意外とElixirと相性が良いです。

理由は、AIエージェントが単なる「1回のHTTPリクエスト」ではないからです。

実際には、

  • 複数モデルを並列に呼ぶ
  • ストリーミング結果をLiveViewへ流す
  • ツール承認待ちで一時停止する
  • キャンセルできるようにする
  • Runごとの状態を持つ
  • PubSubで画面へイベントを流す
  • 一部のモデルが失敗しても全体を継続する
  • 承認の TTL タイマーをプロセスに持たせる

といった処理が必要になります。

これはElixir/OTPが得意な領域です。

「AIエージェント = 並行処理 + 状態管理 + UIストリーミング + 時限制御」と考えると、Elixirはかなり自然な選択肢でした。


まず全体像

各Phaseの説明に入る前に、Spec A/B 完了時点での全体像を先に出しておきます。
細かい部分はこれから順に説明していきますが、地図を持って読んでいただければと思います。

このうちLLM Provider層、Run管理層、Tool実行層、評価基盤、そしてあとから入れた Spec A/B のセキュリティ層あたりが特に時間をかけたところです。


Phase 1: LLM Provider抽象層

最初にやったのは、既存のOpenAI専用クライアントを抽象化することです。

最初のプロトタイプでは OpenAIClient に近い形で実装していましたが、そのままだとOllamaやLM Studioを増やすたびに分岐が増えていきます。

そこで、LLM Providerのbehaviourを作りました。

defmodule MrEric.LLM.Provider do
  @callback chat(messages :: list(map()), opts :: keyword()) ::
              {:ok, String.t()} | {:error, term()}

  @callback stream(messages :: list(map()), pid :: pid(), opts :: keyword()) ::
              :ok | {:error, term()}

  @callback list_models(opts :: keyword()) ::
              {:ok, list(map())} | {:error, term()}
end

この抽象層の下に、OpenAI互換APIを扱う実装を置きました。

OllamaやLM StudioはOpenAI互換エンドポイントを使えるので、最初の段階ではネイティブAPIではなく /v1/chat/completions に寄せました。

OpenAI    : https://api.openai.com/v1
Ollama    : http://localhost:11434/v1
LM Studio : http://localhost:1234/v1

これにより、クラウドLLMとローカルLLMを同じインターフェースで扱えるようになりました。


ChatGPT Proログインは実装しない

途中で一度、「ChatGPT Proアカウントにログインして使う」方向も考えました。

しかし、これはやめました。

OpenAIを使う場合は、APIキーを使います。

やる:
- OPENAI_API_KEY によるAPI利用
- OpenAI互換APIとしてOllama/LM Studioを利用
- MrEric自身のログイン機能は将来的に検討

やらない:
- ChatGPT Web UIログイン(Cookie流用、ブラウザ自動操作、スクレイピング)
- ChatGPT OAuth(Codex CLI 方式)経由のサブスク利用
  - 技術的には可能で、Cline や OpenCode などはこの方式を実装している
  - ただし「Codex として振る舞う」前提のシステムプロンプトを強制される制約があり、
    汎用エージェントには合わなかった
  - OpenAI 自身も「プログラマティックな用途には API キー認証を推奨」と公式に書いている

ここは設計上かなり重要でした。
「便利そうだからやる」ではなく、長く保守できる正攻法に寄せました。


Phase 2: Registry / Router / Orchestrator

次に、Providerを選ぶ仕組みを作りました。

Registry

MrEric.LLM.Registry は、利用可能なproviderやモデル一覧を返します。

MrEric.LLM.Registry.providers()
MrEric.LLM.Registry.models(:ollama)
MrEric.LLM.Registry.all_models()

モデル一覧は /v1/models から取得を試みます。

ただし、OllamaやLM Studioが起動していないことは普通にあります。
そのため、モデル一覧取得に失敗してもアプリ全体が落ちないようにしています。

ローカルLLMは「起動していないこともある」前提で扱うのが大事でした。

Router

MrEric.LLM.Router は、役割に応じてprovider/modelを選びます。

[:planner, :local_drafter, :cloud_drafter, :critic, :reviewer, :synthesizer]

たとえば、将来的にはこういう使い分けができます。

現時点ではシンプルなルーティングですが、役割ごとにモデルを差し替えられる構造にしました。

Orchestrator

ここで、単一モデル呼び出しから複数モデル協調へ移行しました。

MrEric.Orchestrator.run(task, opts)

流れはこうです。

drafter系は Task.async_stream で並列実行します。

一部のdrafterが失敗しても、全体をすぐ止めないようにしました。
失敗情報もdraftの一部としてreviewer/synthesizerへ渡します。

この方針にすると、ローカルLLMが落ちていてもクラウドLLM側で継続できます。


Phase 3: LiveViewでprovider/modelを選択

次に、LiveView側のUIを変更しました。

それまではモデル一覧をハードコードしていましたが、Registryベースにしました。

画面側では、選んだprovider/modelを Agent.execute やストリーミング処理へ渡します。

Agent.execute(task, provider: provider, model: model)

ここで重要だったのは、UI上で選んだモデルが実際の実行に渡ることです。

プロトタイプ段階では、「画面ではモデルを選べるが、内部の計画生成・コード生成ではデフォルトモデルが使われる」というズレが起きがちです。

このズレをなくすため、Agent.execute(task, opts) の形にして、provider/modelを明示的に渡せるようにしました。


Phase 4: RunWorker / PubSub / リアルタイム表示

Phase 4では、実行単位を Run として扱うようにしました。

1つのユーザー入力に対して、1つのRunWorkerを立てます。

注: start_run の第2引数 owner_id は Spec B で必須化しました。詳細はあとで触れます。

RunWorkerは、Orchestratorから来るイベントを受け取り、状態を更新してPubSubへ流します。

イベントはたとえばこうです。

{:run_started, %{run_id: run_id, task: task}}
{:stage_started, %{run_id: run_id, role: :planner}}
{:stage_chunk, %{run_id: run_id, role: :planner, chunk: text}}
{:stage_completed, %{run_id: run_id, role: :planner, content: content}}
{:stage_failed, %{run_id: run_id, role: :cloud_drafter, error: reason}}
{:run_completed, %{run_id: run_id, final: final}}
{:run_cancelled, %{run_id: run_id}}
{:tool_approval_required, %{run_id: run_id, approval_id: id, expires_at: ts}}
{:tool_approval_expired, %{run_id: run_id, approval_id: id, reason: reason}}

LiveView側では、roleごとに進捗を表示します。

これにより、複数エージェントが動いている様子を画面で見られるようになりました。

このあたりはLiveViewとPubSubがかなり気持ちよく使えます。


Phase 5A: Tool Policy / Approval Gate

AIエージェントにツールを使わせると、一気に便利になります。

しかし、同時に危険にもなります。

たとえば、

  • ファイルを読む
  • ファイルを書き換える
  • shell commandを実行する
  • git diffを見る
  • patchを適用する

といった操作をAIに任せるなら、安全装置が必要です。

そこで、Tool基盤を作りました。

Tool behaviourは概ねこういう形です。

defmodule MrEric.Tools.Tool do
  @callback name() :: String.t()
  @callback description() :: String.t()
  @callback input_schema() :: map()
  @callback risk_level() :: atom()
  @callback requires_approval?(input :: map()) :: boolean()
  @callback run(input :: map(), context :: map()) ::
              {:ok, map()} | {:error, term()}
end

リスクレベルも持たせました。

[:safe, :low, :medium, :high, :dangerous]

最初に入れたツールは、かなり控えめです。

file_read
file_write_proposal
shell_command
git_status
git_diff

ここで大事なのは、file_write_proposal は実際には書き換えないことです。
あくまで「こういう変更をしたい」という提案だけを返します。

また、shell_command は原則承認必須にしました。

安全性を雑にすると後で怖いので、ここはかなり慎重に作りました。


Phase 5B: 最小RAGとMCP準備

Phase 5Bでは、最小限のRAG基盤も入れました。

ただし、この時点では本格的なベクトルDBやembedding検索は入れていません。

まずは、

  • プロジェクト内ファイルを読む
  • chunk化する
  • キーワード検索する
  • plannerに文脈として渡す

くらいの軽い構成です。

対象は、READMEや lib/**/*.ex などです。

逆に、以下は除外します。

.env
.env.*
.git
deps
_build
node_modules
secret files
credentials
workspace外

補足: 「RAG が secret 系パスを除外する」判断は、後の Spec A で MrEric.Tools.Policy.secret_path?/1 を public 化して RAG.Index と Tool Policy で同じロジックを共有する 形に整理されました。

MCPについては、この段階では本格接続はしません。

MrEric.MCP.ClientBehaviour
MrEric.MCP.ToolAdapter

という土台だけを置きました。

MCP連携は便利ですが、外部ツール実行につながるので、MrEricではMCP toolも必ずApproval Gateを通す方針にしています。


Phase 5C: Tool loop統合

Phase 5Cでは、LLMの出力からtool requestを検出して、RunWorker経由で実行できるようにしました。

流れはこうです。

ツール呼び出しには上限を設けています。

[:max_tool_calls_per_run,
 :max_tool_calls_per_role,
 :max_total_runtime_ms,
 :max_context_chars,
 :max_tool_output_chars]

これは重要です。

AIエージェントは、放っておくとループする可能性があります。
「もう少し情報が必要」と言いながら、延々とfile_readを繰り返すことがあります。

なので、ツール呼び出し回数や出力サイズには必ず上限を入れました。


Phase 6: patch提案と承認後の適用

Phase 6では、AIが提案したpatchを実際に適用できるようにしました。

ただし、無承認では適用しません。

ここで追加したのは、たとえば次のようなツールです。

apply_patch
patch_validator
patch_result

patch適用前には検証します。

- workspace内のファイルか
- secret fileではないか
- patchが大きすぎないか
- binary fileではないか
- beforeが現在内容と一致するか
- 削除patchではないか
- 許可されていない新規拡張子ではないか
- symlink escape していないか

before が現在のファイル内容と一致しない場合は拒否します。
これは、同時編集や古い提案による事故を避けるためです。

また、.env や秘密鍵らしきファイルへのpatchは拒否します。

AIにファイル編集を許すときは、ここを曖昧にしない方がいいです。


Phase 9: Fake Provider / 評価基盤 / Run trace

Phase 7と8を飛ばして、Phase 9を先に入れました。

理由は単純です。

RAGやMCPを高度化する前に、壊れたかどうかを測れるようにしたかったからです。

AIエージェントは、普通の関数テストだけでは品質を見づらいです。

  • Plannerが呼ばれたか
  • Drafterが失敗しても継続したか
  • tool approvalが発生したか
  • patchが承認後だけ適用されたか
  • secretが漏れていないか
  • cancelできたか

こういう流れ全体を、外部APIなしで再現できる必要があります。

そこでFake providerを作りました。

MrEric.LLM.FakeProvider

Fake providerは決定的に動きます。

これにより、外部LLMの揺らぎなしでOrchestratorやRunWorkerをテストできます。


Eval case

評価ケースも作りました。golden case は priv/evals/phase9_golden_cases.json に置いてあります。

たとえば、

simple_planning
local_model_failure_continues
tool_denied
tool_approval_required
tool_approval_rejected
patch_proposal_requires_approval
patch_apply_after_approval
cancelled_run
provider_missing_api_key_error
secret_leak_check

のようなケースです。

概念的にはこういう形です。

%{
  name: "simple_planning",
  task: "Create a simple implementation plan",
  scenario: "simple_planning",
  expected_status: :completed,
  expected_final_contains: ["plan", "implementation"],
  expected_events: [:run_started, :stage_started, :run_completed],
  expected_no_secret_leak: true
}

評価はLLMで採点しません。
ルールベースです。

- finalに期待文字列が含まれるか
- statusが期待通りか
- 期待イベントがtraceにあるか
- forbidden eventが発生していないか
- secret leakがないか
- patchが承認後だけ適用されたか

この方が再現性があります。


Run trace

Phase 9では、Run traceも整備しました。

Runごとに、何が起きたかを追えるようにします。

run_id
task
provider
model
role started/completed/failed
stage chunks
tool requested
tool denied
tool approval required
tool approved
tool rejected
tool expired
tool completed
patch proposal created
patch applied
run completed/failed/cancelled
duration
error classification
metadata

これがあると、失敗したときの原因を追いやすくなります。

AIエージェントは処理が長くなりがちなので、「最終結果が変だった」だけではデバッグできません。

どのroleで失敗したのか。
どのtoolがdenyされたのか。
承認待ちで止まったのか、期限切れになったのか。
patch validationで落ちたのか。

こういう情報をtraceで追えるようにしました。


Secret leak check

Phase 9で特に重要だったのが、secret leak checkです。

AIエージェントは、ファイルを読んだり、tool outputを扱ったり、traceを保存したりします。
そのため、APIキーや秘密情報が混ざる危険があります。

検査対象はこうです。

final
review
drafts
run trace
tool output
patch proposal
eval result
LiveView render結果の一部

検出対象は、たとえば次のようなものです。

OPENAI_API_KEY
OPENROUTER_API_KEY
GROK_API_KEY
XAI_API_KEY
LMSTUDIO_API_KEY
OLLAMA_API_KEY
Bearer token
sk- で始まるキー
private key
access_token
refresh_token
password

テストでは本物のキーは使いません。
必ずダミーsecretを使います。

補足: ここの SecretChecker は、その後 Spec A で Result struct API任意にネストした map / list / tuple を再帰 walk する実装String.to_atom/1 由来の atom-table 枯渇を回避する to_existing_atom/1 へとほぼ書き直されました。次節で触れます。


mix task

評価はmix taskから実行できるようにしました。

mix mr_eric.evals

普段の検証はこうしています。

mix format
mix test
mix precommit
mix mr_eric.evals

外部APIを呼ばずに評価できるので、CIやローカル開発で安心して回せます。


Spec A: secret hygiene の最低ライン整備

Phase 9 を入れた直後、コードを見直したところ、評価基盤より前に塞いでおくべき穴 がいくつか残っていることに気付きました。

「テストで secret 漏れを検出できる」のは前進ですが、その下地となる「そもそも secret を漏らしにくい状態」がまだ甘かったのです。

Spec A では、以下を一気に整理しました。

1. secret_key_base のハードコードを除去

最初は dev/test 用の config/dev.exssecret_key_base がベタで書かれていました。これは origin に push されたら復旧不能なので、runtime.exs で乱数生成 → 環境変数優先 に切り替えました。

同時に、

  • .env 系を .gitignore に追加
  • .env.example を追加して、安全な形でキー名だけ共有

しました。config/runtime.exs のテストも追加して、二度と固定値に戻らないようにしています。

2. shell_command の env を deny-list → allow-list に反転

それまで shell_command ツールは「危険そうな env 変数を消す」deny-list でした。これは 新しい sensitive 変数を追加するたびに deny-list を更新しないと漏れる ので、構造的に弱いです。

そこで allow-list 方式に反転しました。

既定で渡す env:
  PATH, HOME, USER, LANG, LC_ALL, TERM, TZ, TMPDIR, SHELL
  パターン: ^LC_

それ以外(GITHUB_TOKEN, DATABASE_URL, OPENAI_API_KEY ...)
は子プロセスに一切渡らない。

設定で拡張も可能ですが、

config :mr_eric, :shell_env_allowlist,
  names: ~w(PATH HOME USER LANG LC_ALL TERM TZ TMPDIR SHELL MIX_ENV),
  patterns: [~r/^LC_/, ~r/^MR_ERIC_/]

ここに key, token, password っぽい名前を入れた場合は 起動時に 1 回だけ警告ログ を出します。

これで、shell_command が間接的に環境変数を流出させるリスクをかなり潰せました。

3. SecretChecker の作り直し

Phase 9 の SecretChecker には、地味だが致命的な不具合が複数残っていました。

  • 「sensitive な key かどうか」の判定ロジックが反転していた
  • ネストした map / list / tuple の中までは見ていなかった
  • 任意の文字列を String.to_atom/1 していて、外部入力で atom table を枯渇させ得た

Spec A ではこれを Result struct ベースの API に書き直し、

  • 任意のネストを再帰 walk
  • 既知 atom にだけ変換するように to_existing_atom/1
  • scorer 側も actual map 全体を SecretChecker.scan/1 に渡す

という形に整えました。

4. RAG.Index と Tool Policy で secret パス判定を共有

Phase 9 まで、MrEric.RAG.Index は独自に「読まないファイル」を判定していました。一方 Tool Policy 側は別ロジックでした。2 箇所に同じ意図のロジックがあると片方だけ更新されて穴が空きます

Spec A で MrEric.Tools.Policy.secret_path?/1 を public に昇格し、RAG.Index も同じ関数を呼ぶようにしました。

これで、

  • Tool 側でブロックされる secret path は RAG indexing 側でも必ずスキップ
  • 新しい sensitive path パターンを 1 箇所追加すれば両方に効く

という状態になりました。

Spec A まとめ

「機能を増やす Phase」とは違って、Spec A はほぼ全部 後退防止のための作業 です。
派手さはないですが、ここをやらないと Phase 9 の eval 自体の信頼性が薄くなります。


Spec B: Run ownership と approval lifecycle

Spec A の次に取り組んだのが Spec B です。

Phase 9 までの MrEric は、ローカル単独利用を前提にしていました。
そのため、

  • Run には所有者の概念がなかった
  • 承認 token は実質「持っている人なら誰でも使える」状態
  • 承認待ちで放置された Run は、いつまでも待ち続けた

という性質がありました。これらは「将来 multi-user にしたとき詰む」前に整理しておきたい部分です。

Spec B では、「セッションに紐づく owner_id を Run に必ず持たせ、approval は owner と TTL で縛る」 という方針で実装しました。

1. Session に owner_id を払い出す plug

MrEric.Plugs.EnsureOwnerId:browser pipeline に挟み、session に owner_id がなければ 16 byte の乱数 base64url を払い出します。

defmodule MrEric.Plugs.EnsureOwnerId do
  import Plug.Conn

  @session_key :owner_id

  def init(opts), do: opts

  def call(conn, _opts) do
    case get_session(conn, @session_key) do
      nil ->
        owner_id =
          16
          |> :crypto.strong_rand_bytes()
          |> Base.url_encode64(padding: false)

        put_session(conn, @session_key, owner_id)

      _existing ->
        conn
    end
  end
end

idempotent なので、同じブラウザは同じ owner_id を持ち続けます。

2. Run に owner_id を必須化

MrEric.Runs.Run:owner_id フィールドを追加し、Run.new(task, opts)Keyword.fetch!(opts, :owner_id) するようにしました。
owner_id を渡し忘れた呼び出しは起動時にクラッシュ します。これは、抜け漏れを実行時に発見できる素直なやり方です。

3. Runs API も owner_id 必須

MrEric.Runs.start_run(task, owner_id, opts)
MrEric.Runs.cancel_run(run_id, owner_id)
MrEric.Runs.approve_tool(run_id, approval_id, owner_id)
MrEric.Runs.deny_tool(run_id, approval_id, owner_id)

すべて owner_id を要求します。MrEric.Runs.OwnerCheck.verify/2 で「Run.owner_id と一致しているか」を確認し、違えば {:error, :not_owner} を返します。

4. Approval HMAC に owner_id を bind

承認 token は元々 HMAC でしたが、Spec B では (tool, args, approval_id, tool_call_id, owner_id) をまとめて HMAC するようにしました。

これにより、別 owner が たまたま approval_id を知っていても token は再現できません。

defp approval_token(tool, args, approval_id, tool_call_id, owner_id) do
  data = :erlang.term_to_binary({tool, args, approval_id, tool_call_id, owner_id})
  :crypto.mac(:hmac, :sha256, secret_key(), data) |> Base.url_encode64(padding: false)
end

5. expires_at と TTL タイマー

Approval request に expires_at を持たせ、既定で 30 分 で期限切れにします。

実装としては 2 系統あります。

  • Reactive: 承認・拒否のリクエストが来た瞬間に expires_at を見て、過ぎていれば :approval_expired
  • Proactive: 万一誰も触らなくても、Process.send_after/3 で時刻が来たら tool_approval_expired イベントを broadcast。

両方を入れているので、「承認 UI を開いたまま 1 時間放置 → ユーザーが承認ボタンを押す」みたいな状況でも、必ず安全側に倒れます。

6. Run 終了時に未消化 approval を expire

Run が :cancelled / :failed / :completed で終わるとき、まだ pending の approval があれば tool_approval_expired を broadcast してから落ちるようにしました。

LiveView 側もこのイベントを handle_info で受け、UI のボタンを無効化するなどの処理を入れています。

これがないと、Run は終わったのに承認 UI だけ生き残って「押してもエラー」という気持ち悪い状態になります。

Spec B まとめ

Spec B は地味ですが、「multi-user / 長時間放置 / 別タブから操作」みたいな現実の使い方 を後で支えるための基礎になっています。


良かった設計判断

1. ChatGPT Proログインをやめたこと

これは早めに判断してよかったです。
Web UIログインやCookie流用に寄せると、保守性も安全性も悪くなります。
APIキー方式とOpenAI互換APIに寄せたことで、OpenAI、Ollama、LM Studioを同じ構造で扱えるようになりました。

2. Provider抽象を先に作ったこと

最初に LLM.Provider を作ったことで、後からFake providerを入れるのが楽になりました。
Fake providerはPhase 9でかなり効きました。
外部APIなしでOrchestrator全体を評価できるようになったからです。

3. Approval Gateを先に作ったこと

ツール実行は便利ですが、危険です。
先にApproval Gateを作っておいたことで、apply_patchやshell_commandを追加しても安全側に倒せました。
さらに Spec B で owner_id バインド + TTL を後から差し込めたのも、Approval Gate を入口に揃えていたおかげです。

4. Phase 7/8よりPhase 9を優先したこと

高度なRAGやMCP連携は魅力的です。
でも、それらを入れる前に評価基盤を作った方がよいと判断しました。
これは正解でした。

AIエージェントは、機能を増やすほど挙動が複雑になります。
先にtraceとevalsを作っておくと、後の変更が怖くなくなります。

5. Phase 9 の直後に Spec A / Spec B を挟んだこと

Phase 7 や Phase 8 の前に、もう一段「セキュリティの最低ライン」を整えました。

  • Spec A: 漏らしにくい状態にする
  • Spec B: 操作できる人と時間を絞る

機能追加の Phase に進む前にこれをやっておくと、次の Phase で attack surface が増えても 被害が広がりにくい 構造になります。


苦労したところ

ストリーミングと状態管理

LLMのstreaming出力を扱いながら、roleごとの状態をLiveViewに表示するのは少し複雑でした。

stage_started
stage_chunk
stage_completed
stage_failed

のようにイベントを分けることで、整理しやすくなりました。

一部失敗しても継続する設計

複数モデルを使う場合、どれか1つは失敗します。

Ollamaが起動していない。
LM Studioにモデルがロードされていない。
APIキーがない。
タイムアウトする。

こういうことは普通にあります。

そのため、「すべて成功しないと完了しない」ではなく、「一部失敗しても、失敗情報を含めて進む」設計にしました。

ツール承認待ち

RunWorkerが実行中に承認待ちになると、単純なrequest-responseでは扱いにくくなります。

ここはElixirのプロセスモデルが役に立ちました。

RunWorkerが状態を持ち、LiveViewから承認イベントを受けて処理を継続します。
Spec B の TTL タイマーも、Process.send_after/3 を Worker 自身に向けて発射するだけで素直に書けました。

owner_id を必須化したときの破壊的変更

Run.new/2Keyword.fetch!(:owner_id) にした瞬間、テストや内部呼び出しが軒並み落ちます

ここは「失敗するなら早く失敗する」方針で、fetch を強制し、テスト側では with_owner_session/2 ヘルパーを ConnCase に追加して受け止めました。
中途半端に「owner_id がなければデフォルト値」みたいな道を残すと、Spec B の前提が一瞬で崩れるので、踏み込んで正解でした。

secret 検出の atom 化問題

最初の SecretChecker は、map の key 判定で String.to_atom/1 を使っていました。これだと 外部入力で atom table を膨らませられる 危険があります。

Spec A で String.to_existing_atom/1 に置き換え、未知 key は文字列のまま比較するようにしました。
こういう「セキュリティの罠」は、書いている時はあまり気付きにくいです。テストと併せて見直して良かった部分です。


今後やりたいこと

まだ構築しはじめたばかり。
これからです。

Phase 11 残件: 出力前 redaction の共通層

Spec A / B で多くの基礎は固まりましたが、まだやり残しがあります。

  • Secret Redactor(検査だけでなく 出力前に必ず redact する共通層)
  • PathPolicy
  • OutputLimiter
  • Concurrency limit
  • Run cleanup
  • Security evals の追加

Spec A で「混入を防ぐ」「混入を検出する」までは入りました。次は「もし混入しても 外に出す前に必ず削る」レイヤーを 1 か所に集約したいです。

Phase 12: ドキュメント整理

ここまでくると、READMEやAGENTS.mdがかなり重要になります。

  • 起動方法
  • provider設定
  • Ollama / LM Studio設定
  • Tool approval flow
  • patch apply flow
  • evals
  • セキュリティ方針(Spec A / Spec B 含む)
  • 既知の制限

を整理したいです。

Elixir Desktop化

MrEricはローカルAIエージェントハーネスなので、Elixir Desktop化とも相性がよさそうです。

Web版を壊さず、Desktop modeでLiveViewをローカルウィンドウ表示できるようにするのは、次の実験候補です。

OllamaやLM Studioをローカルで起動して、MrEric Desktopから接続する形にすると、かなり使いやすくなりそうです。
Spec B の owner_id は、Desktop 単独ユーザーの場合は「常に同じ owner_id」を 1 つ払い出すだけで対応できる想定です。

RAG高度化(Phase 7 本番)

現時点のRAGは最小構成です。

今後は、

  • embedding provider
  • keyword / embedding / hybrid search
  • context compression
  • index metadata強化
  • reindex UI

などを入れたいです。Spec A で Policy.secret_path?/1 を共有化したので、ここを強化しても secret 漏れの面ではブレにくくなっています。

MCP連携(Phase 8 本番)

MCPは土台だけ置いています。

本格的にやるなら、

  • MCP server config
  • MCP tool一覧
  • MCP tool proxy
  • Approval Gate経由のMCP実行(owner_id + TTL も継承)
  • credentials保護

が必要です。

MCP toolも通常のtoolと同じく、安全ポリシー・owner check・TTL を通す方針です。


まとめ

Elixir/Phoenix/LiveViewでAIエージェントハーネスを作るのは、かなり手応えがあり、面白いです。

Phase 9 と Spec A/B までで、MrEric は単なる LLM チャットアプリではなく、

  • 複数LLM provider
  • 複数roleの協調実行
  • LiveViewによるリアルタイム表示
  • RunWorkerによる状態管理
  • Tool Policy / Approval Gate
  • patch提案と承認後適用
  • Fake providerによる評価
  • Run trace
  • Secret leak check
  • secret hygiene の最低ライン(Spec A)
  • session-bound owner_id と approval TTL(Spec B)

を持つ構成になりました。

まだ完成ではありません。
ただ、ここまでで「安全に育てられる土台」はかなりしっかりしてきたと思います。

AIエージェントは、機能を増やすだけなら簡単です。
でも、実用的にするには、

が必要です。

MrEricでは、そのあたりをElixirらしくOTPとLiveViewで組み立てています。

次は Phase 11 の残件(出力前 redaction の共通層) に進み、その後で Phase 7 / Phase 8 の本番相当(高度な RAG と MCP 連携)に踏み込んでいく予定です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?