Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ <h3>Vault &amp; Secret Management</h3>
<div class="feature">
<div class="feature-icon">&#129302;</div>
<h3>Model Registry</h3>
<p>Backend-keyed model registry with per-backend probes, SQLite persistence, and ETS read-through cache. Four write sources (baseline, probe, session hook, on-demand), ETS heir crash survival, and exponential backoff with graceful degradation on failures.</p>
<p>Backend-keyed model registry with auth-based auto-discovery via <code>BeamAgent.Auth</code> and session-based model listing through temporary <code>BeamAgent.Catalog</code> sessions. SQLite persistence, ETS read-through cache, four write sources (baseline, probe, session hook, on-demand), ETS heir crash survival, and exponential backoff with graceful degradation on failures.</p>
</div>
</div>
</section>
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,15 +532,20 @@ cached models grouped by provider, trigger refresh).
Unified model cache keyed on `(backend, provider)` with per-backend
probes, SQLite persistence, and ETS read-through for low-latency reads.

Five-layer architecture:
Auto-discovers authenticated backends via `BeamAgent.Auth.status/1` on
each tick when no backends are configured. The BeamAgent adapter starts
a temporary session per backend, queries `BeamAgent.Catalog.supported_models/1`
from the CLI init handshake data, and normalizes results to `model_attrs`
shape. No direct HTTP calls to provider APIs.

Four-layer architecture:

| Layer | Module | Owns |
|-------|--------|------|
| **ModelRegistry** | `MonkeyClaw.ModelRegistry` | GenServer — ETS table lifecycle, tick scheduler, per-backend probe dispatch, serialized writes via single upsert funnel |
| **ModelRegistry** | `MonkeyClaw.ModelRegistry` | GenServer — ETS table lifecycle, tick scheduler, per-backend probe dispatch, auth-based auto-discovery, serialized writes via single upsert funnel |
| **CachedModel** | `MonkeyClaw.ModelRegistry.CachedModel` | Ecto schema — `(backend, provider)` unique key, embedded model list, trust-boundary changeset validation |
| **Baseline** | `MonkeyClaw.ModelRegistry.Baseline` | Boot seed loader — reads baseline model entries from `runtime.exs`, cold-start availability |
| **EtsHeir** | `MonkeyClaw.ModelRegistry.EtsHeir` | ETS crash survival — heir process reclaims the table when the registry crashes and re-transfers on restart |
| **Provider** | `MonkeyClaw.ModelRegistry.Provider` | HTTP fetching via Req for Anthropic, OpenAI, and Google APIs; called by the BeamAgent backend adapter |

Four independent writers populate the cache: **Baseline** (boot seed),
**Probe** (periodic per-backend tasks via `TaskSupervisor`),
Expand Down
15 changes: 15 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,20 @@ config :monkey_claw, MonkeyClaw.ModelRegistry.Baseline,
models: [
%{model_id: "gemini-2.5-pro", display_name: "Gemini 2.5 Pro", capabilities: %{}}
]
},
%{
backend: "copilot",
provider: "github_copilot",
models: [
%{model_id: "gpt-4o", display_name: "GPT-4o", capabilities: %{}},
%{model_id: "claude-3.5-sonnet", display_name: "Claude 3.5 Sonnet", capabilities: %{}}
]
},
%{
backend: "opencode",
provider: "anthropic",
models: [
%{model_id: "claude-sonnet-4-6", display_name: "Claude Sonnet 4.6", capabilities: %{}}
]
}
]
175 changes: 145 additions & 30 deletions lib/monkey_claw/agent_bridge/backend/beam_agent.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ defmodule MonkeyClaw.AgentBridge.Backend.BeamAgent do
@moduledoc """
Production backend adapter wrapping the BeamAgent runtime.

Each callback delegates to the corresponding `BeamAgent` or
`BeamAgent.Threads` function. This module exists solely to
satisfy the `MonkeyClaw.AgentBridge.Backend` behaviour contract,
keeping the Session GenServer decoupled from the concrete
BeamAgent API.
Session lifecycle, queries, threads, and checkpoints delegate
directly to `BeamAgent` and `BeamAgent.Threads`.

Model listing uses beam_agent's own session-based catalog:
checks `BeamAgent.Auth.status/1` for authentication, starts a
temporary session to query `BeamAgent.Catalog.supported_models/1`,
normalizes the results to `model_attrs` shape, and stops the
session. No direct HTTP calls to provider APIs.

This is the default backend used when no `:backend` key is
present in the session config.
"""

@behaviour MonkeyClaw.AgentBridge.Backend

alias MonkeyClaw.ModelRegistry.Provider

@impl true
def start_session(opts), do: BeamAgent.start_session(opts)

Expand Down Expand Up @@ -82,47 +83,161 @@ defmodule MonkeyClaw.AgentBridge.Backend.BeamAgent do
@impl true
def thread_list(pid), do: BeamAgent.Threads.thread_list(pid)

# ── Model Listing ───────────────────────────────────────────

# Known beam_agent backends for validation and normalization.
@known_backends ~w(claude codex copilot opencode gemini)a

@dialyzer {:nowarn_function, list_models: 1}
@impl true
def list_models(opts) when is_map(opts) do
# This adapter authenticates via CLI auth status, not vault secrets.
# Opts like :workspace_id and :secret_name are defined in the
# behaviour type for adapters that use direct API key auth, but
# BeamAgent session startup handles auth internally — only :backend
# is consumed here.
backend = Map.get(opts, :backend)
provider = backend_to_provider(backend)

provider_opts =
opts
|> Map.to_list()
|> Keyword.take([:workspace_id, :secret_name, :api_key, :base_url])
with {:ok, backend_atom} <- normalize_backend(backend),
{:ok, %{authenticated: true}} <- BeamAgent.Auth.status(backend_atom) do
list_models_via_session(backend_atom)
else
{:ok, %{authenticated: false}} ->
{:error, :not_authenticated}

Comment thread
beardedeagle marked this conversation as resolved.
{:error, _} = error ->
error
end
end

# Start a temporary session, wait for the CLI init handshake to
# complete, query the backend's model catalog, and stop.
#
# start_session/1 returns {:ok, pid} before the session state machine
# reaches :ready — the CLI init handshake runs asynchronously.
# supported_models/1 reads from init_response, which is only populated
# once the handshake completes. await_session_ready/2 polls health/1
# to gate the catalog query.
#
# The 15s readiness timeout is well within ModelRegistry's 30s probe
# deadline.
@spec list_models_via_session(atom()) :: {:ok, [map()]} | {:error, term()}
defp list_models_via_session(backend_atom) do
provider = backend_to_provider(backend_atom)

case Provider.fetch_models(provider, provider_opts) do
{:ok, models} ->
{:ok, Enum.map(models, &annotate_provider(&1, provider))}
case BeamAgent.start_session(%{backend: backend_atom}) do
{:ok, session} ->
try do
with :ok <- await_session_ready(session, 15_000) do
case BeamAgent.Catalog.supported_models(session) do
{:ok, models} when is_list(models) ->
{:ok, Enum.map(models, &normalize_model(&1, provider))}

{:error, _} = error ->
error
end
end
after
BeamAgent.stop(session)
end
Comment thread
beardedeagle marked this conversation as resolved.

{:error, _} = error ->
error
end
end

# Map the MonkeyClaw backend identifier to the upstream provider name.
# Static table — future SDK and local backends extend this.
defp backend_to_provider(atom) when is_atom(atom) and not is_nil(atom),
do: backend_to_provider(Atom.to_string(atom))
# Poll BeamAgent.health/1 until the session reaches :ready state,
# indicating the CLI init handshake has completed and init_response
# is populated with the backend's model catalog.
#
# Returns :ok when ready, {:error, :session_not_ready} on timeout or
# terminal state (:error, :unknown).
@poll_interval_ms 100
@spec await_session_ready(pid(), integer()) :: :ok | {:error, :session_not_ready}
defp await_session_ready(_session, remaining_ms) when remaining_ms <= 0 do
{:error, :session_not_ready}
end

defp await_session_ready(session, remaining_ms) do
case BeamAgent.health(session) do
:ready ->
:ok

state when state in [:connecting, :initializing] ->
Process.sleep(@poll_interval_ms)
await_session_ready(session, remaining_ms - @poll_interval_ms)

_terminal ->
{:error, :session_not_ready}
end
end

# Validate and normalize the backend identifier to a known atom.
# Accepts atoms, binaries, and strings. Returns {:error, ...} for
# unrecognized backends so the probe reports a clear failure rather
# than crashing.
@spec normalize_backend(term()) :: {:ok, atom()} | {:error, {:unknown_backend, term()}}
defp normalize_backend(backend) when is_atom(backend) and backend in @known_backends,
do: {:ok, backend}

defp normalize_backend(backend) when is_binary(backend) do
atom = String.to_existing_atom(backend)
if atom in @known_backends, do: {:ok, atom}, else: {:error, {:unknown_backend, backend}}
rescue
ArgumentError -> {:error, {:unknown_backend, backend}}
end

defp normalize_backend(other), do: {:error, {:unknown_backend, other}}

# Map MonkeyClaw backend atoms to their upstream provider string.
# Single source of truth for the :provider field in model_attrs.
@dialyzer {:nowarn_function, backend_to_provider: 1}
@spec backend_to_provider(atom()) :: String.t()
defp backend_to_provider(:claude), do: "anthropic"
defp backend_to_provider(:codex), do: "openai"
defp backend_to_provider(:gemini), do: "google"
defp backend_to_provider(:opencode), do: "anthropic"
defp backend_to_provider(:copilot), do: "github_copilot"
defp backend_to_provider(other), do: Atom.to_string(other)

# Normalize a beam_agent model entry (binary or atom keys from the
# CLI init handshake JSON) into the model_attrs shape expected by
# CachedModel.changeset/2.
@dialyzer {:nowarn_function, normalize_model: 2}
@spec normalize_model(map() | binary(), String.t()) :: map()
defp normalize_model(model_id, provider) when is_binary(model_id) do
%{provider: provider, model_id: model_id, display_name: model_id, capabilities: %{}}
end

defp backend_to_provider("claude"), do: "anthropic"
defp backend_to_provider("codex"), do: "openai"
defp backend_to_provider("gemini"), do: "google"
defp backend_to_provider("opencode"), do: "anthropic"
defp backend_to_provider("copilot"), do: "github_copilot"
defp backend_to_provider(nil), do: "anthropic"
defp backend_to_provider(other) when is_binary(other), do: other
defp normalize_model(model, provider) when is_map(model) do
model_id =
coalesce_key(
model,
[:model_id, "model_id", :model, "model", :name, "name", :id, "id"],
"unknown"
)

defp annotate_provider(%{model_id: id, display_name: name, capabilities: caps}, provider) do
%{
provider: provider,
model_id: id,
display_name: name,
capabilities: caps
model_id: to_string(model_id),
display_name:
to_string(coalesce_key(model, [:display_name, "display_name", :name, "name"], model_id)),
capabilities: coalesce_key(model, [:capabilities, "capabilities"], %{})
}
end

# Return the first non-nil value found for any of the candidate keys,
# or the default when none match.
@spec coalesce_key(map(), [atom() | String.t()], term()) :: term()
defp coalesce_key(map, keys, default) do
Enum.reduce_while(keys, default, fn key, acc ->
case Map.fetch(map, key) do
{:ok, value} when not is_nil(value) -> {:halt, value}
_ -> {:cont, acc}
end
end)
end

# ── Checkpoint Operations ────────────────────────────────────

@impl true
Expand Down
Loading
Loading