small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
albedo docs extensions.md
9.1 kB · 140 lines
Markdown
at main

Extensions #

Extensions are basically bundles that provide capabilities to the harness such as oauth, tools, compaction strategies, ways to inject context to the agent, and more. Each extension has a name, description, a list of extensions it depends on, a list of plugins that the extension's capabiltiies fit into, and an initializer.

Extensions can be installed and yet be optional. They can also be toggable as a global default or turned on/off per session.

For a list of built in extensions, see src/albedo/harenss/extensions/

Plugins #

Plugins are typed contribution points and an extension can provide several kinds:

  • context: load text for the harness’s model context.
  • tools: add model-callable tools, Python modules, and host RPC routes.
  • managed: prepare session-specific contributions that depend on the sqlite db, observe session events, and hook into turns.
  • commands: provide commands shared by the CLI and the Python kernel callable by the model.
  • compaction / folds: provide compaction strategies and stored folds (previous compactions).
  • models / model provider: provide catalog data for an inference provider or bridges for oauth from providers such as Claude and Codex
  • login: provide a browser-based sign-in flow, often depended on by model provider extensions.
  • service: mount daemon HTTP routes under the extension’s path.
  • migration: contribute schema setup or data upgrades.
  • settings: declare the values the extension reads from its section of $ALBEDO_HOME/extensions.json (key, label, description, default, and the range or kind it accepts). The daemon lists and saves them through the extension_settings settings group, checking a save with the extension's own rules first, and /config in the TUI edits them. See extension settings.

On installation/startup the host calls each extension’s initializer and applies schema migrations. When a session’s enabled extensions are composed, their plugins supply the context, tools, commands, strategies, and other session-facing capabilities. Services and model-provider capabilities are used by daemon-level code rather than being ordinary per-session tools.

Activation and reload #

For implementation and review rules, see extension boundaries.

Each extension activates as a complete bundle. A failed context loader, managed preparation, or capability collision removes all of that extension's contributions. Extensions that require it also become inactive. The runtime closes rejected managed resources; unrelated extensions keep working.

Saving preferences changes the desired selection. Reload applies it to the session. If application fails, the desired choice remains saved for retry. A matching Python module set keeps the current kernel and rebinds its routes; a changed set stages a replacement and restores its namespace before retiring the old kernel. Failures before shutdown retain the old kernel. Failures after shutdown clear its handle instead of claiming rollback.

Host routes accept a host.Context and request string. The context contains the store, session ID, and search providers from the loaded active composition. Global HTTP services use globally enabled extensions instead of session overrides. Search preferences rank or exclude eligible providers without enabling an inactive extension.

Managed Plugins #

A managed plugin has a prepare function that receives the store (the daemon’s durable SQLite-backed store), session id, and workspace, and it returns a Managed value. That value can contribute context, instructions, tools, Python modules, routes, commands, and warnings, plus an event observer, a hook, and a close callback.

The observer can receive session events such as provider calls, turn completions, compaction, and session changes. It runs inside the session actor, so it must send and return rather than block or do lengthy work. The Session capability exposed to managed extensions can make a background provider call, but that call is exclusive work and fails if another run is active or the kernel is released

Example of creating an extension #

A very small existing extension is run. It contributes a tool plugin: instructions for the model, a Python module named run, and a host route named jobs.

pub fn extension() -> harness_extension.Extension {
  harness_extension.Extension(
    "run",
    "Start supervised programs from Python, without a shell.",
    ["python"],
    [
      harness_extension.ToolPlugin(instructions, [], ["run"], [
        #("jobs", fn(context: host.Context, request) {
          route(context.store, context.session, request)
        }),
      ]),
    ],
    harness_extension.no_initialise,
  )
}

To wire this in for now, import its module and add the extension to the extensions list in harness/extensions.gleam. In the future, there'll be an easier way to load extensions in.

An example of a managed plugin #

Scheudle is a builtin managed plugin. It prepares a session-specific /schedule command using that session’s store and id. It’s managed because the command needs session-specific state

pub fn extension() -> extension.Extension {
  extension.Extension(
    "schedule",
    "Durable session prompts, recurring reminders, and idle heartbeats.",
    ["python"],
    [
      extension.ManagedPlugin(fn(db, session, _) {
        Ok(
          extension.Managed(
            ..extension.empty(),
            commands: [command(db, session)],
          ),
        )
      }),
    ],
    ledger.initialise,
  )
}

The prepare callback here receives db (the store), session (the session id), and the workspace (unused, hence _). It returns a Managed value with the command wired to that session. The extension’s initializer sets up the schedule ledger.

Hooks #

A managed value can also carry a hook: hook: Some(fn(hook.Event) -> hook.Verdict). The turn asks it at four points and waits for the answer:

  • Prompted(text): a user message is about to reach the model.
  • Calling(call): a tool call is about to run.
  • Called(call, output, started_ms, finished_ms): the call has a result. A refused call gets one too.
  • Stopping(reply, held): the model ended its turn without calling a tool. held counts how often hooks already kept this turn going.

The answer is a Verdict. hook.proceed changes nothing. Otherwise it can set block (refuse the call or prompt, or keep a stopping turn going), arguments (run the call with this JSON instead), context (text the model reads before its next request), notes (shown to the user only), or halt (end the turn). Every hook in the session is asked in registry order. The first block, rewrite and halt win, context and notes add up, and a hook that crashes becomes a note.

Hooks run in the turn's own process, so a slow one slows the turn. One that only watches should send the event to a process of its own and return hook.proceed at once.

The agents extension hooks child sessions. A child about to stop while it still owes its parent an answer is asked once to send it:

fn answered(
  db: store.Store,
  session: String,
  parent: String,
  event: hook.Event,
) -> hook.Verdict {
  case event {
    hook.Stopping(held: 0, ..) ->
      case mail.owes_reply(db, session, parent) {
        Ok(True) ->
          hook.Verdict(
            ..hook.proceed,
            block: Some("You have not answered your parent yet. ..."),
          )
        _ -> hook.proceed
      }
    _ -> hook.proceed
  }
}

Calls inside a cell #

Most of what the model does happens inside one python call, so hooks also hear about the calls a cell makes: run, files.edit, files.write, mcp.call, mail.submit, the agents calls that change another session, web_search, the browser actions, every cua driver call, and the remote equivalents. Their within names the model call that ran the cell. A block raises PermissionError in the cell.

A Python plugin reports a call of its own with albedo_plugins.hook. Decorate the method and map its parameters to the argument names hooks see:

from albedo_plugins import hook

class Mail:
    @hook.reported("mail.submit", {"to": "to", "body": "body"})
    async def submit(self, to: object, body: str) -> Receipt:
        ...

It works on sync and async methods. When the arguments are not the method's parameters, wrap the call itself: await hook.around(name, arguments, call) runs call on the arguments the hooks left. hook.before(name, arguments) and hook.after(call, output) are the two halves, for code that has to report the end later, like a job. The kernel loads this module only while some extension in the session has a hook, so a session without hooks pays nothing.

Command hooks #

The hooks extension (off by default) runs Claude Code style command hooks from $ALBEDO_HOME/hooks.json. It is an ordinary user of the seam above. The file format, matchers, exit codes and output fields are in robot-docs/hooks.md.