Skip to content

Agent Plugin Walkthroughs and Migration

In a nutshell

Two front-to-back worked examples for writing an agent engine plugin (a DocumentIndexerEngine and a ChatEngine), a guide for migrating an existing QuestionSolver-family plugin to the agent engine API, and a pre-publish checklist follow. For picking the right base class and the rules every engine follows, see Building Agent Plugins.


Walkthrough: a DocumentIndexerEngine

A minimal but real plugin, front to back. The example wraps a naive keyword index. Swap the internals for embeddings and this structure does not change.

Project layout:

ovos-notes-retrieval-plugin/
├── pyproject.toml
└── ovos_notes_retrieval/
    └── __init__.py

ovos_notes_retrieval/__init__.py:

from typing import Dict, List, Optional, Tuple

from ovos_plugin_manager.templates.agents import DocumentIndexerEngine


class NotesRetrieval(DocumentIndexerEngine):
    """Keyword search over an in-memory document corpus."""

    def __init__(self, config: Optional[dict] = None):
        super().__init__(config)
        self.documents: List[str] = []
        self.min_score: float = self.config.get("min_score", 0.0)

    def ingest_corpus(self, corpus: List[str]):
        self.documents += corpus

    def query(self, query: str, lang: Optional[str] = None,
              k: int = 3) -> List[Tuple[str, float]]:
        lang = lang or self.lang
        words = set(query.lower().split())
        scored = []
        for doc in self.documents:
            hits = words & set(doc.lower().split())
            score = len(hits) / len(words) if words else 0.0
            if score > self.min_score:
                scored.append((doc, score))
        scored.sort(key=lambda pair: pair[1], reverse=True)
        return scored[:k]

pyproject.toml. The entry point is what makes it a plugin. The group must match the base class, and the entry-point name (left of =) is the string users put in their config:

[project]
name = "ovos-notes-retrieval-plugin"
version = "0.1.0"
dependencies = ["ovos-plugin-manager>=2.4.0a1"]

[project.entry-points."opm.agents.retrieval.documents"]
ovos-notes-retrieval = "ovos_notes_retrieval:NotesRetrieval"

Every opm.agents.* group has a parallel *.config group (here opm.agents.retrieval.documents.config) where a plugin can register a dict of config metadata for installers and GUIs. It is optional. Add it once the plugin has settings worth advertising.

Test it without OVOS. Engines are plain classes, so a unit test is just:

from ovos_notes_retrieval import NotesRetrieval

engine = NotesRetrieval()
engine.ingest_corpus(["the mail server runs on port 25",
                      "backups run every night at 3am"])
results = engine.query("when do backups run?", k=1)
assert "backups" in results[0][0]

Verify discovery after pip install -e .:

from ovos_plugin_manager.agents import find_document_indexer_plugins
print(find_document_indexer_plugins())
# {'ovos-notes-retrieval': <class 'ovos_notes_retrieval.NotesRetrieval'>}

Each group has a matching find_*_plugins() / load_*_plugin(name) pair in ovos_plugin_manager.agents, such as find_chat_plugins and find_reranker_plugins. load_* returns the uninstantiated class. You construct it with your config dict.


Walkthrough: a ChatEngine

The same skeleton for a conversational engine, wrapping any backend that takes a message list:

from typing import Iterable, List, Optional

from ovos_plugin_manager.templates.agents import (AgentMessage, ChatEngine,
                                                  MessageRole, ToolsArg)


class MyChatEngine(ChatEngine):

    supports_tools = False  # set True only if you handle the tools arg

    def continue_chat(self, messages: List[AgentMessage],
                      session_id: str = "default",
                      lang: Optional[str] = None,
                      units: Optional[str] = None,
                      tools: ToolsArg = None) -> AgentMessage:
        lang = lang or self.lang
        # 1. filter roles your backend does not understand
        history = [m for m in messages
                   if m.role in (MessageRole.SYSTEM, MessageRole.USER,
                                 MessageRole.ASSISTANT)]
        # 2. call the backend
        reply: str = self._backend_generate(history, lang)
        # 3. wrap the reply
        return AgentMessage(role=MessageRole.ASSISTANT, content=reply)

    def stream_sentences(self, messages, session_id="default",
                         lang=None, units=None) -> Iterable[str]:
        # override only if the backend can really stream;
        # yield complete sentences, safe for TTS
        yield from self._backend_stream(messages, lang or self.lang)

Registered under:

[project.entry-points."opm.agents.chat"]
my-chat-engine = "my_chat_plugin:MyChatEngine"

Once installed, a persona can select it by entry-point name, with no code changes anywhere else in the stack. See Agents & Personas for the persona JSON format.


Migrating a solver plugin to an agent engine

The legacy QuestionSolver family (opm.solver.* entry points, classes in ovos_plugin_manager.templates.solvers) is deprecated. The entry-point-to-class mapping lives in Deprecated Solver Types. This section covers the code changes, which are small but not mechanical.

1. Pick the agent base class.

Solver class Agent base class
QuestionSolver ChatEngine (or RetrievalEngine — see exception below)
ChatMessageSolver ChatEngine
TldrSolver SummarizerEngine
EvidenceSolver ExtractiveQAEngine
MultipleChoiceSolver ReRankerEngine
EntailmentSolver NaturalLanguageInferenceEngine

One exception: if your QuestionSolver was really a knowledge lookup (Wikipedia, Wolfram Alpha, a database), migrate it to RetrievalEngine instead of ChatEngine. The solver API has no retrieval type, so a lookup must take the Q&A shape. The agent API has a separate type for it.

2. Rename the methods. For a QuestionSolver moving to ChatEngine, the core change is from string-in/string-out to messages-in/message-out:

# before (QuestionSolver)
def get_spoken_answer(self, query: str, lang=None, units=None) -> str:
    return self._answer(query)

# after (ChatEngine)
def continue_chat(self, messages, session_id="default",
                  lang=None, units=None, tools=None) -> AgentMessage:
    query = messages[-1].content  # last message is the user turn
    return AgentMessage(role=MessageRole.ASSISTANT,
                        content=self._answer(query))

ChatMessageSolver.get_chat_completion maps almost one-to-one to continue_chat. Wrap the returned string in an AgentMessage and update the dict-style messages to AgentMessage dataclasses. stream_utterances becomes stream_sentences.

3. Drop the auto-translation assumptions. This is the trap. QuestionSolver has enable_tx and bidirectional auto-translation: a solver can declare supported_langs = ["en"] and still receive every language, because the base class translates the input and output behind the scenes.

Agent engines never translate. After migration your engine receives the user's language as-is. Either handle it or return nothing for languages you do not support.

Translation is the recommended strategy for engines whose backend is inherently monolingual (Wolfram Alpha and most web APIs only speak English). The agent base class does not translate for you, so the plugin translates explicitly. Use the translation plugin system instead of hardcoding one translator. OVOSLangTranslationFactory.create() (in ovos_plugin_manager.language) returns the LanguageTranslator the user configured in the central "language" section of mycroft.conf, so every plugin honors one shared translation config:

from ovos_plugin_manager.language import OVOSLangTranslationFactory

class WolframEngine(RetrievalEngine):
    def __init__(self, config=None):
        super().__init__(config)
        self.translator = OVOSLangTranslationFactory.create()

    def query(self, query, lang=None, k=3):
        lang = lang or self.lang
        if lang != "en":
            query = self.translator.translate(query, target="en", source=lang)
        answer = self._ask_wolfram(query)
        if lang != "en":
            answer = self.translator.translate(answer, target=lang, source="en")
        return [(answer, 1.0)]

One warning about local translation models: several OVOS components can each load translation plugins, and each create() call builds its own instance. With a heavy local model such as NLLB, that means the same weights in memory once per component. On such setups configure a remote translation plugin (a translate server) so the model loads once, in one process, and everything else calls it over HTTP.

4. Drop solver-specific plumbing. There is no priority attribute, no internal cache, and no spoken_answer convenience wrapper. Persona config order replaces priority. ChatEngine.get_response(utterance) replaces spoken_answer for quick tests.

5. Swap the entry point. Change the group in pyproject.toml, keep the entry-point name if you can. Users reference plugins by that name in persona files, so keeping it makes the migration invisible to them:

# before
[project.entry-points."opm.solver.question"]
my-plugin = "my_plugin:MySolver"

# after
[project.entry-points."opm.agents.chat"]
my-plugin = "my_plugin:MyChatEngine"

During a transition period a plugin may register under both groups, with the solver class delegating to the agent class. Ship the dual registration in a minor release, then drop the solver entry point in the next major.

How ovos-persona loads legacy solvers

You do not need to migrate everything at once, because ovos-persona loads both generations side by side. Its QuestionSolversService merges the plugin registries into a single handler pool: legacy opm.solver.question and opm.solver.chat plugins load right next to opm.agents.chat, opm.agents.chat.multimodal, and the three retrieval groups.

At answer time the service walks the persona's handler list in config order and dispatches on type:

  • ChatEngine / MultimodalChatEngine → continue_chat (or stream_sentences when streaming), full history passed through.
  • RetrievalEngine / DocumentIndexerEngine / QAIndexerEngine → query(last_utterance, k=1), and the top document becomes the answer.
  • ChatMessageSolver (legacy) → get_chat_completion with the full message list.
  • QuestionSolver (legacy) → spoken_answer(last_utterance). Chat history is silently dropped, because the solver contract is single-turn. This is the main functional reason to migrate conversational plugins: under a persona, a QuestionSolver answers every turn as if it were the first.

The first handler that returns a non-empty answer wins. A persona JSON can therefore mix generations freely during migration:

{
  "name": "MixedPersona",
  "solvers": [
    "my-chat-engine",
    "ovos-solver-failure-plugin"
  ]
}

The "solvers" key is legacy naming. Use "handlers". ovos-persona accepts both. The solver classes are deprecated, and a plugin you write must not use them.


Checklist before you publish

  1. The class subclasses exactly one agent base class and implements all its abstract methods with the documented signatures and return types.
  2. The entry-point group in pyproject.toml matches that base class (see the table in Building Agent Plugins).
  3. __init__ accepts config=None and works with an empty config.
  4. lang=None falls back to self.lang, and unsupported languages fail clearly instead of silently degrading.
  5. Unit tests exercise the engine directly, with no OVOS services running.
  6. find_*_plugins() discovers the installed plugin under the expected name.

Read next: Agentic Loop Architectures Related: Building Agent Plugins · Agent Engine Types · Agent Tool Plugins · Personas & PersonaService