Search plugin manifests by meaning, or say you cannot

7e7a4bc65202 · AtlantisPleb · · parent 8906ed6efa3f

Search plugin manifests by meaning, or say you cannot

The registry indexed manifests and left selection to the caller,
because there was no embedding path to select with. There was one: the
tool catalog has done exactly this job for a while — vectors cached
against a digest, cosine in process, and a provider that is allowed to
be absent.

Plugin manifests now use the same shape. A search embeds the query,
scores it against the indexed manifests' discovery descriptions, and
returns the best matches with their scores.

The workspace rule holds where it matters most, in the fallback. When
embeddings are disabled or the provider errors, the search returns the
candidate manifests for the caller to choose from — it does not quietly
degrade to substring matching, because a keyword match dressed as
semantics is worse than admitting there is no ranking. Invocation
stays exact-name throughout; this changes selection only.

Embeddings stay off by default, following the tool catalog's existing
posture, and a plugin search can never fail a request because the
embedding provider is down.

Built by a Devin child through the openagents coder's delegate tool;
207 plugin and tool-discovery tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 362 · 2026-08-25T10:45:10.330851Z

Changed files

  • modified config/config.exs
  • added lib/openagents/plugins/discovery/doc.ex
  • added lib/openagents/plugins/embeddings.ex
  • modified lib/openagents/plugins/index.ex
  • added test/openagents/plugins/search_test.exs
  • added test/support/openagents/plugins/embeddings_error_provider.ex
  • added test/support/openagents/plugins/embeddings_test_provider.ex

Diff

7 files changed, +430 -4

config/config.exs modified +8

@@ -249,6 +249,14 @@ config :openagents,

249 249
    dimensions: 64,
250 250
    top_k: 12
251 251
  ],
252
  plugin_discovery: [
253
    embeddings_enabled: false,
254
    provider: OpenAgents.Memory.OpenAIEmbeddings,
255
    model_id: "text-embedding-3-small",
256
    model_version: "2024-01",
257
    dimensions: 64,
258
    top_k: 12
259
  ],
252 260
  # The shipped tool catalog. It is a zero base, deliberately small.
253 261
  #
254 262
  # A tool ships only when it meets every admission criterion in
lib/openagents/plugins/discovery/doc.ex added +57

@@ -0,0 +1,57 @@

1
defmodule OpenAgents.Plugins.Discovery.Doc do
2
  @moduledoc """
3
  The searchable document for one plugin manifest.
4
5
  The discovery text folds the manifest's name and description together with the
6
  text declared on its surfaces and capabilities. It is what gets embedded for
7
  semantic search; it does not synthesize a keyword routing table.
8
  """
9
10
  @spec text(map()) :: String.t()
11
  def text(%{} = manifest) do
12
    [manifest["name"], manifest["description"]]
13
    |> Enum.reject(&(&1 in [nil, ""]))
14
    |> Enum.concat([capability_text(manifest["capabilities"])])
15
    |> Enum.concat(surface_texts(manifest["surfaces"]))
16
    |> Enum.reject(&(&1 in [nil, ""]))
17
    |> Enum.join(" ")
18
  end
19
20
  def text(_), do: ""
21
22
  defp capability_text(nil), do: nil
23
24
  defp capability_text(caps) do
25
    case List.wrap(caps["hosts"]) do
26
      [] -> nil
27
      hosts -> Enum.join(hosts, " ")
28
    end
29
  end
30
31
  defp surface_texts(nil), do: []
32
33
  defp surface_texts(surfaces) when is_list(surfaces),
34
    do: Enum.flat_map(surfaces, &surface_texts/1)
35
36
  defp surface_texts(%{} = surface) do
37
    [surface["name"], surface["description"]]
38
    |> Enum.reject(&(&1 in [nil, ""]))
39
    |> Enum.concat(command_texts(surface["slash_commands"]))
40
    |> Enum.concat(tool_texts(surface["tools"]))
41
  end
42
43
  defp command_texts(nil), do: []
44
45
  defp command_texts(commands) when is_list(commands),
46
    do: Enum.flat_map(commands, &command_texts/1)
47
48
  defp command_texts(%{} = command),
49
    do: [command["command"], command["description"]] |> Enum.reject(&(&1 in [nil, ""]))
50
51
  defp tool_texts(nil), do: []
52
53
  defp tool_texts(tools) when is_list(tools), do: Enum.flat_map(tools, &tool_texts/1)
54
55
  defp tool_texts(%{} = tool),
56
    do: [tool["name"], tool["description"]] |> Enum.reject(&(&1 in [nil, ""]))
57
end
lib/openagents/plugins/embeddings.ex added +150

@@ -0,0 +1,150 @@

1
defmodule OpenAgents.Plugins.Embeddings do
2
  @moduledoc """
3
  Vector embeddings for the plugin registry, for cosine-similarity discovery.
4
5
  There are only a handful of plugins, so the vectors live in a `persistent_term`
6
  cache keyed by a digest over the indexed manifest set and cosine is computed in
7
  process. The provider is the same embedding boundary the memory and tool
8
  discovery systems use.
9
10
  If embeddings are disabled, unconfigured, or the provider errors, `warm/1` and
11
  `embed_query/1` return `:ok`/`:error` and search falls back to the unranked
12
  candidate list. A plugin search must never fail because the embedding provider
13
  is down.
14
  """
15
16
  alias OpenAgents.Plugins.Discovery.Doc
17
  alias OpenAgents.Plugins.Index
18
  alias OpenAgents.Provenance.Canonical
19
20
  require Logger
21
22
  @persistent_key {__MODULE__, :vectors}
23
24
  @doc "Whether embedding-backed plugin discovery is switched on and configured."
25
  @spec enabled?() :: boolean()
26
  def enabled? do
27
    config = Application.get_env(:openagents, :plugin_discovery, [])
28
    Keyword.get(config, :embeddings_enabled, false) == true and not is_nil(provider())
29
  end
30
31
  @doc """
32
  Compute and cache the plugin vectors for the provided entries.
33
34
  Safe to call repeatedly; returns `:ok` when disabled, already cached, or
35
  successful, and `:error` on a provider failure (leaving the cache empty so
36
  callers fall back to the unranked candidate list).
37
  """
38
  @spec warm([Index.Entry.t()]) :: :ok | :error
39
  def warm(entries) when is_list(entries) do
40
    cond do
41
      not enabled?() -> :ok
42
      is_map(cached(digest(entries))) -> :ok
43
      true -> build_and_cache(entries)
44
    end
45
  rescue
46
    error ->
47
      Logger.warning(
48
        "plugin_embeddings_warm_failed code=#{OpenAgents.OperationalLog.code(error)}"
49
      )
50
51
      :error
52
  end
53
54
  @doc "Cached plugin entry → vector map for a digest, or nil when unavailable."
55
  @spec vectors(String.t()) :: %{optional(Index.Entry.t()) => [float()]} | nil
56
  def vectors(digest) when is_binary(digest), do: cached(digest)
57
  def vectors(_digest), do: nil
58
59
  @doc "Embed a query string, or `:error` when embeddings are unavailable."
60
  @spec embed_query(String.t()) :: {:ok, [float()]} | :error
61
  def embed_query(text) when is_binary(text) and text != "" do
62
    if enabled?() do
63
      case provider().embed(text, embed_config()) do
64
        {:ok, vector} when is_list(vector) and vector != [] -> {:ok, vector}
65
        _other -> :error
66
      end
67
    else
68
      :error
69
    end
70
  rescue
71
    _error -> :error
72
  end
73
74
  def embed_query(_text), do: :error
75
76
  @doc false
77
  @spec digest([Index.Entry.t()]) :: String.t()
78
  def digest(entries) when is_list(entries) do
79
    entries
80
    |> Enum.sort_by(&{&1.repository, &1.release, &1.manifest["name"]})
81
    |> Enum.map(fn %Index.Entry{} = entry ->
82
      %{
83
        "repository" => entry.repository,
84
        "release" => entry.release,
85
        "manifest" => entry.manifest
86
      }
87
    end)
88
    |> then(&Canonical.digest!/1)
89
  end
90
91
  defdelegate cosine(a, b), to: OpenAgents.Tools.Embeddings
92
93
  # ── internal ───────────────────────────────────────────────────────────────
94
95
  defp build_and_cache(entries) do
96
    digest = digest(entries)
97
98
    vectors =
99
      entries
100
      |> Enum.reduce_while(%{}, fn entry, acc ->
101
        case provider().embed(Doc.text(entry.manifest), embed_config()) do
102
          {:ok, vector} when is_list(vector) and vector != [] ->
103
            {:cont, Map.put(acc, entry, vector)}
104
105
          _other ->
106
            {:halt, :error}
107
        end
108
      end)
109
110
    case vectors do
111
      :error ->
112
        :error
113
114
      map when map_size(map) > 0 ->
115
        :persistent_term.put(@persistent_key, {digest, map})
116
117
        Logger.info(
118
          "plugin_embeddings_warmed digest=#{binary_part(digest, 0, 12)} count=#{map_size(map)}"
119
        )
120
121
        :ok
122
123
      _empty ->
124
        :ok
125
    end
126
  end
127
128
  defp cached(digest) do
129
    case :persistent_term.get(@persistent_key, nil) do
130
      {^digest, map} -> map
131
      _absent_or_stale -> nil
132
    end
133
  end
134
135
  defp provider do
136
    :openagents
137
    |> Application.get_env(:plugin_discovery, [])
138
    |> Keyword.get(:provider)
139
  end
140
141
  defp embed_config do
142
    config = Application.get_env(:openagents, :plugin_discovery, [])
143
144
    %{
145
      model_id: Keyword.get(config, :model_id, "text-embedding-3-small"),
146
      model_version: Keyword.get(config, :model_version, "2024-01"),
147
      dimensions: Keyword.get(config, :dimensions, 64)
148
    }
149
  end
150
end
lib/openagents/plugins/index.ex modified +54 -4

@@ -9,16 +9,18 @@ defmodule OpenAgents.Plugins.Index do

9 9
10 10
  require Logger
11 11
12
  alias OpenAgents.Plugins.Embeddings
12 13
  alias OpenAgents.Plugins.Manifest
13 14
14 15
  defmodule Entry do
15 16
    @moduledoc "One indexed, validated plugin release."
16
    defstruct [:repository, :release, :manifest]
17
    defstruct [:repository, :release, :manifest, :score]
17 18
18 19
    @type t :: %__MODULE__{
19 20
            repository: String.t(),
20 21
            release: String.t(),
21
            manifest: map()
22
            manifest: map(),
23
            score: float() | nil
22 24
          }
23 25
  end
24 26

@@ -58,12 +60,60 @@ defmodule OpenAgents.Plugins.Index do

58 60
59 61
  @doc "Render an index entry as a JSON-friendly map."
60 62
  @spec to_map(Entry.t()) :: map()
61
  def to_map(%Entry{repository: repository, release: release, manifest: manifest}) do
62
    %{
63
  def to_map(%Entry{repository: repository, release: release, manifest: manifest, score: score}) do
64
    base = %{
63 65
      "repository" => repository,
64 66
      "release" => release,
65 67
      "manifest" => manifest
66 68
    }
69
70
    if is_number(score), do: Map.put(base, "score", score), else: base
71
  end
72
73
  @doc """
74
  Search plugin manifests for `query` and return `{:ok, [Entry.t()]}`. When
75
  embeddings are enabled and available, results are sorted by cosine similarity
76
  and each entry has its `score` set; otherwise the unranked candidate list is
77
  returned for the caller to choose from. The call never raises because of a
78
  provider failure.
79
  """
80
  @spec search(String.t(), keyword()) :: {:ok, [Entry.t()]}
81
  def search(query, opts \\ []) when is_binary(query) do
82
    candidates = list(opts)
83
    top_k = Keyword.get(opts, :top_k, default_top_k())
84
85
    with true <- query != "" and Embeddings.enabled?(),
86
         {:ok, query_vector} <- Embeddings.embed_query(query),
87
         digest <- Embeddings.digest(candidates),
88
         vectors <- Embeddings.vectors(digest) || warm_and_vectors(candidates),
89
         true <- is_map(vectors) do
90
      ranked =
91
        candidates
92
        |> Enum.map(fn entry ->
93
          vector = Map.get(vectors, entry, [])
94
          score = Embeddings.cosine(query_vector, vector)
95
          %{entry | score: score}
96
        end)
97
        |> Enum.sort_by(fn entry -> {-entry.score, entry.manifest["name"]} end)
98
        |> Enum.take(top_k)
99
100
      {:ok, ranked}
101
    else
102
      _ -> {:ok, candidates}
103
    end
104
  end
105
106
  defp warm_and_vectors(candidates) do
107
    case Embeddings.warm(candidates) do
108
      :ok -> Embeddings.vectors(Embeddings.digest(candidates))
109
      :error -> nil
110
    end
111
  end
112
113
  defp default_top_k do
114
    :openagents
115
    |> Application.get_env(:plugin_discovery, [])
116
    |> Keyword.get(:top_k, 12)
67 117
  end
68 118
69 119
  defp fetch(module) when is_atom(module), do: module.entries()
test/openagents/plugins/search_test.exs added +124

@@ -0,0 +1,124 @@

1
defmodule OpenAgents.Plugins.SearchTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Plugins.Index
5
6
  @fixture_path "test/fixtures/plugin_manifest.json"
7
  @weather_manifest %{
8
    "manifest_version" => 1,
9
    "name" => "weather_check",
10
    "version" => "0.1.0",
11
    "author" => "OpenAgents",
12
    "description" => "Check the local weather forecast and report current conditions.",
13
    "artifact" => %{
14
      "path" => "weather_check.wasm",
15
      "digest" => "sha256:0000000000000000000000000000000000000000000000000000000000000000"
16
    },
17
    "abi" => %{
18
      "kind" => "packet-v0",
19
      "entry" => "handle_packet",
20
      "alloc" => "packet_alloc"
21
    },
22
    "interface" => %{
23
      "input" => %{"type" => "object"},
24
      "output" => %{"type" => "object"}
25
    },
26
    "capabilities" => %{
27
      "mounts" => [],
28
      "hosts" => [],
29
      "timeout_ms" => 1000,
30
      "memory_max_mib" => 64
31
    },
32
    "price_msats" => nil,
33
    "license" => "MIT"
34
  }
35
36
  setup do
37
    original = Application.get_env(:openagents, :plugin_discovery)
38
    :persistent_term.put({OpenAgents.Plugins.Embeddings, :vectors}, nil)
39
40
    on_exit(fn ->
41
      :persistent_term.put({OpenAgents.Plugins.Embeddings, :vectors}, nil)
42
      Application.put_env(:openagents, :plugin_discovery, original)
43
    end)
44
45
    :ok
46
  end
47
48
  defp git_manifest do
49
    @fixture_path
50
    |> File.read!()
51
    |> Jason.decode!()
52
  end
53
54
  defp git_entry,
55
    do: %{
56
      repository: "OpenAgentsInc/git-lost-work",
57
      release: "main",
58
      raw_manifest: git_manifest()
59
    }
60
61
  defp weather_entry,
62
    do: %{
63
      repository: "OpenAgentsInc/weather-check",
64
      release: "main",
65
      raw_manifest: @weather_manifest
66
    }
67
68
  describe "search/2" do
69
    test "ranks manifests by cosine when embeddings are enabled" do
70
      Application.put_env(:openagents, :plugin_discovery,
71
        embeddings_enabled: true,
72
        provider: OpenAgents.Plugins.EmbeddingsTestProvider,
73
        model_id: "test",
74
        model_version: "test",
75
        dimensions: 4,
76
        top_k: 2
77
      )
78
79
      {:ok, [first, second]} = Index.search("git history", source: [git_entry(), weather_entry()])
80
81
      assert first.manifest["name"] == "git_lost_work"
82
      assert first.score > 0.0
83
      assert second.manifest["name"] == "weather_check"
84
      assert second.score == 0.0
85
      assert first.score > second.score
86
    end
87
88
    test "returns the unranked candidate list when embeddings are disabled" do
89
      Application.put_env(:openagents, :plugin_discovery,
90
        embeddings_enabled: false,
91
        provider: OpenAgents.Plugins.EmbeddingsTestProvider,
92
        model_id: "test",
93
        model_version: "test",
94
        dimensions: 4,
95
        top_k: 1
96
      )
97
98
      {:ok, [first, second]} = Index.search("git history", source: [git_entry(), weather_entry()])
99
100
      assert first.manifest["name"] == "git_lost_work"
101
      assert is_nil(first.score)
102
      assert second.manifest["name"] == "weather_check"
103
      assert is_nil(second.score)
104
    end
105
106
    test "returns the unranked candidate list instead of raising when the provider errors" do
107
      Application.put_env(:openagents, :plugin_discovery,
108
        embeddings_enabled: true,
109
        provider: OpenAgents.Plugins.EmbeddingsErrorProvider,
110
        model_id: "test",
111
        model_version: "test",
112
        dimensions: 4,
113
        top_k: 1
114
      )
115
116
      {:ok, [first, second]} = Index.search("git history", source: [git_entry(), weather_entry()])
117
118
      assert first.manifest["name"] == "git_lost_work"
119
      assert is_nil(first.score)
120
      assert second.manifest["name"] == "weather_check"
121
      assert is_nil(second.score)
122
    end
123
  end
124
end
test/support/openagents/plugins/embeddings_error_provider.ex added +8

@@ -0,0 +1,8 @@

1
defmodule OpenAgents.Plugins.EmbeddingsErrorProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Memory.EmbeddingProvider
5
6
  @impl true
7
  def embed(_text, _config), do: {:error, :test_provider_error}
8
end
test/support/openagents/plugins/embeddings_test_provider.ex added +29

@@ -0,0 +1,29 @@

1
defmodule OpenAgents.Plugins.EmbeddingsTestProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Memory.EmbeddingProvider
5
6
  @words ["git", "history", "weather", "forecast"]
7
8
  @impl true
9
  def embed(text, %{dimensions: dimensions}) do
10
    vector = for _ <- 1..dimensions, do: 0.0
11
    tokens = text |> String.downcase() |> String.split(~r/[^a-z0-9]+/u, trim: true)
12
13
    vector =
14
      Enum.reduce(tokens, vector, fn token, acc ->
15
        case Enum.find_index(@words, &(&1 == token)) do
16
          nil ->
17
            acc
18
19
          idx when idx < dimensions ->
20
            List.update_at(acc, idx, &(&1 + 1.0))
21
22
          _other ->
23
            acc
24
        end
25
      end)
26
27
    {:ok, vector}
28
  end
29
end

This page updates live while a promote is in flight · changelog