Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: %{}}
]
}
]
195 changes: 165 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,181 @@ 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.
#
# The defensive {:ok, map()} clauses handle backends that return the
# raw models metadata object (with "availableModels" sub-key) instead
# of a flat list. Dialyzer flags these as unreachable based on the
# upstream spec, but they're needed for runtime safety.
@dialyzer {:nowarn_function, list_models_via_session: 1}
@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 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))}

case Provider.fetch_models(provider, provider_opts) do
{:ok, models} ->
{:ok, Enum.map(models, &annotate_provider(&1, provider))}
# Some backends (Gemini) wrap model data in a map with
# an "availableModels" sub-key when the raw init_response
# is returned instead of a flat list. Extract the nested
# list so the probe doesn't crash on a CaseClauseError.
{:ok, %{"availableModels" => models}} when is_list(models) ->
{:ok, Enum.map(models, &normalize_model(&1, provider))}

{:ok, %{<<"availableModels">> => models}} when is_list(models) ->
{:ok, Enum.map(models, &normalize_model(&1, provider))}

{:ok, _unexpected} ->
{:error, :unsupported_model_format}

{: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 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 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(nil), do: {:error, :backend_required}
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 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