Sharing data between extensions

Extensions are separate bundles that should not import each other's classes (one can be unloaded and installed again while another keeps running). There are three ways to work together anyway:

You want Use
to offer text about a book or a story part to whoever wants it (and to use what others offer), without knowing who they are IPCService
to offer tools for the model (functions it can call, and the same as commands) that the user chooses in a Tools dialog ToolService
to use one particular extension and what it can do (objects, lists), if it is installed a direct call

IPCService: text for whoever asks

IPCService lets extensions offer data to each other as text, through callbacks registered by a key. The application can use it too.

There are two kinds of providers:

Kind Callback Asked with
About a book ManuscriptPluginData.getDataFor(Manuscript, Bundle caller, String callerIdentifier) getManuscriptData(...)
About a story part ChatMessagePluginData.getDataFor(ChatMessage, Bundle caller, String callerIdentifier) getChatMessageData(...)

A callback returns the text, or null / blank when it has nothing. It may throw: the exception reaches the caller.

Offering data

Register in onExtensionLoad, with @Autowired IPCService in your @Configurable extension class:

ipcService.registerManuscriptProvider(
        bundle.getSymbolicName(),   // bundleIdentifier: who offers it
        "outline",                  // providerKey: stable, callers remember it
        "Arc outline",              // providerName: what the user sees
        (manuscript, caller, callerIdentifier) -> service.outlineText(manuscript));
  • Use the symbolic name of your bundle as the identifier. When an extension is unloaded the application removes all providers of bundle.getSymbolicName(). A provider registered under another identifier must be removed with unregisterAll(identifier) in onExtensionUnload.

  • Registering a key again replaces the provider and keeps its place in the order.

  • The key is part of what other extensions store (Side Query remembers the sources the user checked), so don't rename it.

Asking for data

Map<String, Map<String, String>> providers = ipcService.getManuscriptProviders();
// bundle identifier -> (provider key -> provider name), both in the order of registration; a copy

String text = ipcService.getManuscriptData("arc-outline", "outline", manuscript,
        bundle /* your own bundle, null for the application */,
        MyExtension.class.getName() /* unique identifier of the caller */);

getChatMessageProviders() and getChatMessageData(...) are the same for story parts. Asking for a provider that is not there (any more) returns null instead of throwing, since extensions come and go at any time; list the providers again when you show them to the user, and call them on the thread you are on (the callback runs there, so the session and transaction are yours).

The callback gets the caller's bundle and identifier, so a provider can decide what to give to whom. Nothing is checked by the service itself: a provider gives the data of the book it is handed, so it must only be handed books the caller may read.

ToolService: tools for the model

ToolService lets an extension offer a suite of tools - functions the model can call - to every dialog that runs the model with tools (the generators of the Lore Generator and of the Arc Outline), and the same suite as commands for the command bar and the agents. Neither side imports the other and nothing is found by reflection: the types that cross are the ones of the application (FunctionSet, ToolSelection).

Offering a suite

Implement ToolProvider and register it in onExtensionLoad, with @Autowired ToolService in your @Configurable extension class:

toolService.registerProvider(
        bundle.getSymbolicName(),   // bundleIdentifier: who offers it
        "names",                    // providerKey: stable, callers save it with the choice of the user
        "Name provider",            // the human name, the title of the section in the dialog
        new NameProviderToolProvider(lists, registry));
public interface ToolProvider {
    default List<ToolItem> items() { return List.of(); }                 // what the user can check; none: on or off
    FunctionSet functions(Collection<String> items, ToolRequest request) throws Exception;
    default String defaultHint() { return null; }                        // added to the prompt when the model gets the suite
    default String description() { return null; }                        // shown above the items
    default Object commands() { return null; }                           // @Command suite, the same tools for the command bar and agents
}
  • Items restrict the suite: the Name generator has one for each category of names. functions gets the ids that are checked (and still offered by items()) and returns functions that know only those - the provider does the restricting with its own types, usually a small class that implements its @LLMFunction interface around the real implementation (NameProviderToolProvider.Restricted). A suite without items is on or off; items is then empty. items() is asked on every use, it may change while the application runs. An item id is saved by the user's choice: keep it stable.

  • ToolItem(id, label): the id is what is saved, the label what the user sees.

  • defaultHint is a short text for the model, "when and how to call these": the caller adds the hint of each suite the model gets to its prompt. The user can write their own in the dialog; theirs replaces the default.

  • commands() is a suite of @Command methods (Commands). The service registers it with CommandService together with the provider and removes it with it, so the extension does not call registerCommands / unregisterCommands itself, and the command bar, the agents and the dialogs with tools have one source and one human name.

  • The provider is identified by the symbolic name of the bundle and the key. When an extension is unloaded the application removes all providers of that bundle and their commands (OsgiServiceImpl); registering a key again replaces the provider and keeps its place in the order. A function name must not be used by two suites a user can check together: resolve fails with the names of both providers.

Asking for tools

A dialog needs three things, and ToolSelectionButton does the first two:

ToolSelectionButton tools = new ToolSelectionButton();           // hides itself while no suite is installed
tools.setValue(settings.getTools());                              // a ToolSelection, null for none
tools.addValueChangeListener(settings::setTools);                 // save it, it is plain JSON (Gson)
...
ToolSet set = toolService.resolve(tools.getValue(), new ToolRequest(bundle, MyService.class.getName()));
inference.stream(payload, protocol, set.functions(), callback);   // null when the user chose nothing
prompt += "\n\n" + set.hint();                                    // the hints of the suites the model got
  • ToolSelection is what the user chose: for each provider (bundle/key) the checked item ids, enabled for a suite without items, and the user's hint. Keep it in your settings (the Lore Generator also keeps it with each lore entry it generated). A provider that is not installed (any more) or an item that is gone is ignored by resolve; a provider that throws is logged and left out.

  • ToolSet holds the combined FunctionSet (null when nothing is on) and the hints; ToolSet.NONE for a dialog that gives the model no tools.

  • The button opens ToolSelectionDialog: one section for each suite with a filter field of its own, see UI. Call refresh() when a dialog that stays open may have lost or gained a suite (it also refreshes when it is attached).

Reading what an older version saved

The Lore Generator and the Arc Outline used to save the name categories with the prefix of their source (Generator: , Provider: , none meant the generator) and two hints, and found the name extensions by reflection. Each of them has a small LegacyNameTools that turns those into a ToolSelection for the suites name-generator/names and name-provider/names, once, when the settings are read (migrateLegacyNameTools); what is saved next has only the new form. Do the same when you change what your settings keep.

Calling another extension directly

When an extension needs more than text from another one and ToolService is not what it wants, it calls the other extension's own methods. (The Lore Generator and the Arc Outline did this for the name extensions before ToolService existed; the technique is the same for anything else.)

Do not import the other extension

The obvious way - depend on the other plugin in the pom.xml and import its packages - is the wrong one. A bundle that imports the packages of another bundle is tied to the classes of that bundle: when the other extension is unloaded (Admin → Extensions, an update, a restart of the framework) and loaded again, the importing bundle keeps the old classes, and an extension installed after yours is not seen at all, its classes are other ones. So the bundle does not import them (pom.xml has no dependency on the other plugin), and nothing of it appears in the Import-Package of the manifest. The other extension is found at run time and used by reflection.

Finding and calling it

OsgiService.getLoadedExtensions() lists the extensions that finished loading (LoadedExtension(bundle, extension)), so the other extension is found by the name of its class:

private static MarginaliaExtension find(OsgiService osgiService) {
    for (OsgiService.LoadedExtension loaded : osgiService.getLoadedExtensions()) {
        if (loaded.extension().getClass().getName()
                .equals("com.github.enerccio.marginalia.extensions.namegenerator.NameGeneratorExtension")) {
            return loaded.extension();
        }
    }
    return null;                                   // not installed, stopped or not loaded yet
}

@SuppressWarnings("unchecked")
public static List<String> categories(OsgiService osgiService) {
    MarginaliaExtension extension = find(osgiService);
    if (extension == null) {
        return null;
    }
    try {
        return (List<String>) extension.getClass().getMethod("categories").invoke(extension);
    } catch (ReflectiveOperationException e) {
        throw new IllegalStateException("The name generator has no categories()", e);
    }
}

The rules the bundled code follows:

  • Only types both sides know cross the border: the JDK and the application (List<String>, FunctionSet, Manuscript...). Your bundle can't name a class of the other one. When you need the other extension's own interface, load it with its class loader (extension.getClass().getClassLoader().loadClass(name)) and, if you must implement it, wrap the real object in a java.lang.reflect.Proxy of that interface.

  • Look it up on every call. Keep no reference to the extension, its classes or its Method objects: they would keep an unloaded extension alive. A missing extension is a normal answer (null), not an error: a dialog asks when it opens and hides the feature when there is none. A screen that stays open can follow the loading and unloading with OsgiService.addExtensionListener (the listener is held weakly, keep a reference to it).

  • One class touches the other extension (with the class names and the method names as constants), so that a change on the other side breaks one place and the reflection is not spread over the code. Report a failed call with an exception that says what is missing; the caller decides what to show.

  • Selections and settings are yours: keep what the user chose in your own settings and pass it each time; leave out what the other extension no longer has.

Offering something to be called

An extension that wants to be called gives its MarginaliaExtension class a few public methods that use only JDK and application types. There is no compiler between the two, so treat the class name, the method names and their signatures as a contract: keep them stable, say so in the README of the plugin, and when you must change them, change the callers in the same commit. The offering extension does not need to know who calls it. (The Name generator and the Name provider had categories(), newFunctions() and functions() for the bridges; they are gone, their suites are offered through ToolService.)

Which one?

  IPCService ToolService Direct call
Caller knows the kind of data (about a book / a story part) that it wants tools for the model the other extension and its methods
Several providers yes, listed in the order they registered yes, in sections of one dialog one
Types text FunctionSet, ToolSelection anything the JDK and the application have
The user chooses what to include (the Side Query popover) suites and their items (the Tools dialog) usually nothing, the feature simply appears
When it is missing its entry is not in the list it is not in the dialog, ignored by resolve the lookup returns null

In the bundled extensions

  • Arc Outline offers its outline as two sources about a book, so the user chooses how much the caller gets: arc-outline / outline (the arcs with their states, descriptions and chapters) and arc-outline / outline-with-notes (the same with the note of every arc and chapter).

  • Side Query lists all providers: the button with the plug icon above a tab opens a popover with a checkbox for each. Checked book sources are added to the system message after the lorebook; checked story part sources are asked for each story part in the Chat Logs range and put after the text of the part. The checked sources are stored in the options of the tab, a source that is gone is skipped until it comes back.