Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Plugins5 of 7

How the loader works

About 6 minutes to read

On this page

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:

  1. Registers the companion built-ins by explicit import — 17 modules today.
  2. Registers the action tools from tools.action.ACTION_TOOLS.
  3. 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:

RuleEffect
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 elseSkipped without comment.

Then, per candidate file:

RuleEffect
plugin:<name> is in the disabled setThe file is never opened. The plugin gets a Settings row with the switch off.
The file executes without raisingIts module object is inspected.
The module has a SCHEMA that is a dictIt becomes a tool, registered under plugin:<name>.
The module has no SCHEMASkipped 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

RuleEffect
The distribution declares an entry point in group kotoba.toolsIt is a candidate; its plugin name is the entry-point key.
plugin:<key> is in the disabled setep.load() is never called. The plugin gets a Settings row with the switch off.
ep.load() returns without raisingThe result is inspected.
The result has a SCHEMA that is a dictIt 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:

bash
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:

python
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:

bash
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:

  1. Folder plugins, in sorted directory order. A tool named twin in aaa/ beats the same name in zzz/.
  2. 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.

  1. The process starts. kotoba.tools is imported, which runs discover().
  2. discover_plugins() needs to know what is switched off — but engine.start() has not run yet, so the in-memory registry's disabled set is still empty. It reads app_settings.saved_disabled_toolsets(), straight from ~/.kotoba/settings.yaml, and unions it with whatever the registry does know.
  3. A plugin named there is not opened. _remember_disabled records {"source": ..., "tools": [], "loaded": False} so Settings has a row to draw a switch on. Writing down a directory name is not running its code.
  4. Later, you flip that switch. app_settings.set_toolset_enabled writes the file first, then tells the registry, which calls plugins.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.