Plugins5 of 7
How the loader works
On this page
- When it runs
- What makes a folder plugin valid
- What makes an entry-point plugin valid
- Isolation: a broken plugin cannot take the server down
- What you actually see
- The guard that is not there
- Load order and name collisions
- Against a built-in
- Against another plugin
- Two plugins with the same plugin name
- The disabled path, in the order a real install hits it
What makes a plugin valid, what makes it rejected, what happens when one is broken, and what happens
when two of them want the same name. All of it read off core/plugins.py and tools/registry.py,
and all of it exercised rather than assumed.
When it runs
Once, at import of kotoba.tools. registry.discover() does three things in this order:
- Registers the companion built-ins by explicit import — 17 modules today.
- Registers the action tools from
tools.action.ACTION_TOOLS. - Calls
core.plugins.discover_plugins().
Then discover_plugins() does folder plugins first, entry-point plugins second.
Plugins load last. That is the load order, and everything on the rest of this page follows from it.
What makes a folder plugin valid
Walking _load_folder_plugins in order, an entry in the plugins directory is scanned when:
| Rule | Effect |
|---|---|
It is a directory whose name starts with neither . nor _ | Every *.py directly inside it, whose name does not start with _, is a candidate. Sub-directories are not scanned. |
Or it is a .py file whose name starts with neither . nor _ | The file itself is the candidate, and the plugin name is its stem. |
| Anything else | Skipped without comment. |
Then, per candidate file:
| Rule | Effect |
|---|---|
plugin:<name> is in the disabled set | The file is never opened. The plugin gets a Settings row with the switch off. |
| The file executes without raising | Its module object is inspected. |
The module has a SCHEMA that is a dict | It becomes a tool, registered under plugin:<name>. |
The module has no SCHEMA | Skipped silently. Its module body still ran. |
And finally: a plugin that produced no tools gets no row. _PLUGINS[name] is only written when
at least one file yielded a tool, so a directory whose every file failed, or whose every file lacked
a SCHEMA, appears nowhere in the interface. The one exception is a plugin that was switched off
before it was opened: that one gets a row with an empty tool list, precisely so there is somewhere to
switch it back on.
Files are taken in sorted order, and directories are taken in sorted order.
What makes an entry-point plugin valid
| Rule | Effect |
|---|---|
The distribution declares an entry point in group kotoba.tools | It is a candidate; its plugin name is the entry-point key. |
plugin:<key> is in the disabled set | ep.load() is never called. The plugin gets a Settings row with the switch off. |
ep.load() returns without raising | The result is inspected. |
The result has a SCHEMA that is a dict | It becomes one tool, registered under plugin:<key>. |
If reading the entry points fails at all, _load_entrypoint_plugins returns quietly and no
entry-point plugin loads.
Isolation: a broken plugin cannot take the server down
Three nested guards, and every one of them was checked by running code that trips it.
Per file. spec.loader.exec_module(mod) sits inside a try. A file that raises on import is
logged and skipped, and the loop moves to the next file. The good plugin sitting beside the bad one
still loads, and so does the good file sitting beside the bad file inside the same plugin.
Per phase. discover_plugins() wraps folder discovery and entry-point discovery in separate
try blocks, so a catastrophic failure of one still lets the other run.
Per system. registry.discover() wraps the whole call to discover_plugins() in a try as
well. Compare this with the two lines above it: a failure of the core action tools is re-raised as
RuntimeError("core action tools failed to load ... refusing to start with a crippled toolset"). A
plugin failure is not. She starts.
What you actually see
A directory laid out like this:
~/.kotoba/plugins/
ok/good.py <- a valid tool
bad/boom.py <- import a_module_that_does_not_exist_xyz
produces, on stderr:
plugin 'bad' file boom.py failed to load — skipping
Traceback (most recent call last):
File ".../kotoba/core/plugins.py", line 117, in _load_folder_plugins
spec.loader.exec_module(mod)
...
File ".../plugins/bad/boom.py", line 1, in <module>
import a_module_that_does_not_exist_xyz
ModuleNotFoundError: No module named 'a_module_that_does_not_exist_xyz'
and then she comes up — kotoba doctor under exactly that setup still runs to
Ready. Everything she can use is here. — with good_tool registered and bad absent from
loaded_plugins() entirely: no Settings row, no toolset row, nothing in /settings plugins.
That absence is the thing to know. From the interface, a plugin that crashed on import and a plugin you never installed look identical. If you added one and it is not in the list:
python -c "
import kotoba.tools
from kotoba.core.plugins import loaded_plugins, plugins_dir
print('looking in:', plugins_dir())
print(loaded_plugins())
"
The traceback goes to stderr, not to ~/.kotoba/cli.log. Discovery runs at import of
kotoba.tools, which happens before the CLI redirects logging to its file, so the message reaches
Python's last-resort handler — bare, with no ERROR:kotoba.plugins: prefix. Look at the terminal you
started her in, or at whatever your service manager captures. A plugin that fails later — one you
re-enable from Settings while she is running — does land in the log file.
The guard that is not there
Isolation covers loading. It does not cover running. Once your tool is registered, an
exception inside execute() is caught by the loop and turned into the string
"The tool errored." — with no log line and no traceback anywhere. If you want to know why your
tool failed, catch it yourself and either return the reason or log it:
import logging
log = logging.getLogger("kotoba.plugins.myplugin")
async def execute(args, ctx):
try:
return await do_the_thing(args)
except Exception:
log.exception("myplugin failed")
return "That didn't work — the service returned an error."
And isolation does not cover an execute that is not async. That raises
TypeError: a coroutine was expected out of the tool runner rather than being handled as a tool
failure.
Load order and name collisions
Plugins register after the built-ins, which would normally mean last-one-wins. It does not,
because _register_plugin_module calls register(spec, allow_override=False).
Against a built-in
A folder plugin declaring SCHEMA["name"] = "shell":
WARNING:kotoba.plugins:plugin 'collide' declares tool 'shell', which already exists — not registered
The real shell survives, with its own risk="exec" and its own check() intact. This matters:
without it, a plugin could re-declare shell with risk="read" and walk straight past the risk
filter.
Do not reuse a built-in tool's name. The registration is refused and the plugin does not record the name, so your tool is simply absent — nothing warns you at the point of use. Prefix your tool names, or pick names nobody else would.
Print the names in use before you choose one:
python -c "
import kotoba.tools
from kotoba.tools.registry import registry
print(sorted(registry()))
"
Against another plugin
Same rule, so first-registered wins, and "first" is:
- Folder plugins, in sorted directory order. A tool named
twininaaa/beats the same name inzzz/. - Then entry-point plugins. A folder plugin always beats an entry-point plugin for a contested name.
The loser logs declares tool … which already exists — not registered and its tool is simply not
there. Two plugins fighting over a name is a misconfiguration you have to notice yourself.
Two plugins with the same plugin name
A folder called dice and an entry point called dice are not two plugins. They share one row, one
toolset (plugin:dice) and one switch, and the row's source is whichever loaded first — the
folder. Give your plugin a name nobody else is likely to pick.
The disabled path, in the order a real install hits it
This is the sequence the loader is built around, and it is why the disabled check reads the settings file rather than the registry.
- The process starts.
kotoba.toolsis imported, which runsdiscover(). discover_plugins()needs to know what is switched off — butengine.start()has not run yet, so the in-memory registry's disabled set is still empty. It readsapp_settings.saved_disabled_toolsets(), straight from~/.kotoba/settings.yaml, and unions it with whatever the registry does know.- A plugin named there is not opened.
_remember_disabledrecords{"source": ..., "tools": [], "loaded": False}so Settings has a row to draw a switch on. Writing down a directory name is not running its code. - Later, you flip that switch.
app_settings.set_toolset_enabledwrites the file first, then tells the registry, which callsplugins.load_plugin(name)— which runs the module body now.
If the settings file cannot be read at all, the loader logs a warning and falls back to the registry's own disabled set — never to "nothing is disabled", because that would silently re-enable something you turned off.
