Agent Plugin Walkthroughs and Migration¶
In a nutshell
This page gives 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 new agent engine API, and a pre-publish
checklist. 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/__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,<3.0.0"]
[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:
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 old 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 new base class.
| Old solver class | New 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
had no retrieval type, so lookups were forced into the Q&A shape. The new API separates them.
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 had
enable_tx and bidirectional auto-translation: a solver could declare
supported_langs = ["en"] and still receive every language, because the base class
translated 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 still the recommended strategy for engines whose backend is inherently
monolingual (Wolfram Alpha and most web APIs only speak English). What changed is who
does it: the base class no longer translates for you, so the plugin re-implements it
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 old class delegating to the new one. Ship the dual registration in a minor release, then drop the solver entry point in the next major.
How ovos-persona keeps old solvers working¶
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(orstream_sentenceswhen streaming), full history passed through.RetrievalEngine/DocumentIndexerEngine/QAIndexerEngine→query(last_utterance, k=1), and the top document becomes the answer.ChatMessageSolver(legacy) →get_chat_completionwith the full message list.QuestionSolver(legacy) →spoken_answer(last_utterance). Chat history is silently dropped, because the old contract is single-turn. This is the main functional reason to migrate conversational plugins: under a persona, aQuestionSolveranswers 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:
The "solvers" key is itself legacy naming. Newer configs use "handlers", and
ovos-persona accepts both. One more back-compat layer sits below all this: with
an ovos-plugin-manager from before the early 2.2.x alphas the opm.agents.* registries
do not exist (they landed piecemeal from 2.2.x through 2.4.0a1, where the option-matcher
group arrived last; the example pin of >=2.4.0a1 above is the safe floor that has all of
them), and
ovos-persona stubs them out so solver-only personas keep working. None of this
back-compat is permanent. The solver classes stay loadable until the next major OPM
release, and new plugins must not use them.
Checklist before you publish¶
- The class subclasses exactly one agent base class and implements all its abstract methods with the documented signatures and return types.
- The entry-point group in
pyproject.tomlmatches that base class (see the table in Building Agent Plugins). __init__acceptsconfig=Noneand works with an empty config.lang=Nonefalls back toself.lang, and unsupported languages fail clearly instead of silently degrading.- Unit tests exercise the engine directly, with no OVOS services running.
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