Skip to content

feat(plugin): an OpenClaw 2.0 plugin, and on-demand retrieval on every host - #15

Draft
Tian-yi-Sun wants to merge 5 commits into
mainfrom
feat/openclaw2-and-modes
Draft

feat(plugin): an OpenClaw 2.0 plugin, and on-demand retrieval on every host#15
Tian-yi-Sun wants to merge 5 commits into
mainfrom
feat/openclaw2-and-modes

Conversation

@Tian-yi-Sun

@Tian-yi-Sun Tian-yi-Sun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Adds an OpenClaw 2.0 plugin, and a second retrieval mode across all six hosts.

The second mode

mode selects how skills reach the agent, defaulting to on_demand:

  • on_demand offers a skill_search tool and lets the agent decide. A long task pays for retrieval at the step that needs it and nothing on the turns that do not.
  • auto is the existing behaviour: search every turn, inject what fits.

They are exclusive, not additive — running both would pay for retrieval twice and put the same skill in front of the model from two directions.

host how the tool is offered
OpenClaw 1.x / 2.0 api.registerTool
Hermes the memory provider's get_tool_schemas / handle_tool_call
Raven a second [[plugin.contributes.tools]] entry; each factory declines when it is not the configured mode
DeepSeek Harness ctx.tools.register, scoped with ctx.inject
WorkBuddy an MCP server — its plugin format has no tool slot, but the manifest accepts mcpServers

on_demand is the default because auto's cost is paid continuously and silently, while its own failure mode — an agent that never thinks to search — is what the tool description exists to prevent. That description is therefore load-bearing, and every host's copy has a test asserting on it. See "what on-demand costs" below.

Why a separate OpenClaw 2.0 package

plugin-openclaw injects through the before_prompt_build hook. On OpenClaw 2.0 (2026.8.1) that path is dead for a plugin installed from anywhere but an official source, and dead silently: measured on a real 2.0 install, the handler is never invoked under agent --local, and through the gateway it runs and has its prependContext dropped — with hooks.allowConversationAccess, hooks.allowPromptInjection and --accept-capabilities all granted. Nothing is logged either way.

plugin-openclaw2 registers a context engine instead. Four requirements had to be met before the host would call it, each failing silently:

requirement what happens without it
plugins.slots.contextEngine names the plugin the slot is exclusive; registering is not selecting
info.transcriptSemantics.currentTurnFence degraded to legacy every turn; assemble never called
turnAdvancementIdempotency and a real commitTurn the host checks the declaration and the method together
contracts.tools in the manifest a registered tool is never offered to the model

The 1.x plugin is unchanged in shape and stays for hosts through 2026.7.x. What 2.0's contract gives that the hook did not: assemble receives the turn's prompt, the agent's real availableTools — so the gate's environment check finally runs on OpenClaw — and one engine per workspace. The engine reports ownsCompaction: false and declines compact: it appends to an assembled context and keeps no transcript.

Relationship to Raven's skill tools

Worth stating plainly, because "follow how Raven does it" was the brief and Raven does not do this.

Raven ships read_skill and use_skill. Both are downstream of always-on injection: the # Skills catalog still lands every turn, and the tools let the model pull one candidate's full body, or drop its scripts/ onto disk. That is progressive disclosure — it makes an injected catalog cheaper, not injection optional. What is new here is the model initiating retrieval.

There was no prior implementation to copy, on either side. Verified rather than assumed: git log -S over this repository's whole history and over the old skillsearch_plugins finds no skill_search and no on_demand; current Raven has neither a search tool nor a mode switch. What was borrowed is the shape of a tool contract, not behaviour or wording.

The two are additive. A third shape is available later and is cheaper than either: search on demand, get back a catalog, then fetch bodies with a use_skill-style tool. This PR does not build it.

What running on the real hosts found

The suites passed throughout. Driving all six hosts end to end found six defects anyway — every one invisible to a green suite, because two hosts are not installable in CI and the tests that would have caught the rest exercise a factory instead of the thing the host calls.

defect how it was hidden
Raven's tool was not a Tool — duck-typed against the ABC, so the first real turn died on to_schema the suite drives the factory, never the registry
Three of Raven's segment tests were red @needs_host, so skipped everywhere the suite normally runs
DSH accepted a misspelled mode — z.string() turned mode: "atuo" into a silent downgrade no CI job can typecheck that file
DSH: exec.agent is optional, and toolNames walked the registry twice per call same
DSH: adding 'tools' to inject made an optional service required, so a composition without one would stop loading the plugin entirely — auto mode included same
The tool description was joined with ' ', flattening its paragraph breaks and bulleted list into one line — in all four TypeScript copies nothing asserts on shape

Two more were fixed in passing: engine.ts could not compile under the harness's exactOptionalPropertyTypes, and bundleCacheDir was missing from both OpenClaw manifests — which are additionalProperties: false, so a deployment that set it had its whole config rejected and ran on defaults, silently. A test now pins config.ts against the manifest in both directions.

Raven's on-demand mode had no model

build_plugin_tools passes a factory the config slice and a ServiceLocator and nothing else, so the live LLMProvider the segment factory receives under a private key never reached the tool. The default mode therefore ran with no rewriter and no gate — not a mild degradation, since fusion ranks by position and the gate is the only step that removes a weak best-hit.

The plugin now carries the fallback the other four already had: an OpenAI-compatible client named by ordinary config (model, model_base_url, model_api_key). The host's provider still wins where offered. Verified with on-demand given no provider — the path the fix exists for.

What on-demand costs, measured

Recall in this mode depends entirely on the model choosing to call the tool, which depends entirely on the description. The same skill, on two different hosts:

asked called skill_search?
"build a quarterly deck, follow our established procedure" yes
"what is our internal template name for quarterly decks" no — answered "I don't know", from a workspace where the answer was one search away

Both hosts behaved identically, so this is a property of the mode. The fix was a description clause naming in-house conventions; after it, the failing question answers correctly. Reviewers changing that text should know it is load-bearing.

Verification

Suites: python 103, no-extras 93, parity 36, hermes 23, raven 16 (6 skipped without a checkout; 22/22 against one), openclaw 41, openclaw2 41, workbuddy 43. Both OpenClaw packages pass verify_npm_package.mjs. tsc -b inside a real harness checkout passes — it reported three errors before, and no CI job can run it.

End to end, on real hosts, judged by whether the model states facts that exist only in a retrieved skill body — and, where the harness can see it, by which channel carried them:

host auto on_demand
OpenClaw 2.0 (2026.8.1) pass pass
OpenClaw 1.x (2026.6.11) pass pass
Hermes pass pass — model calls the tool itself
Raven pass pass — with no host-provided model
DeepSeek Harness pass pass — 3/3 runs called the tool
WorkBuddy confirmed on a real install by the maintainer same

WorkBuddy is the one host this branch's author could not drive — it is a desktop app — so its row is the maintainer's result, not a scripted one. What the suite proves there is the MCP server itself: workbuddy 43 includes spawning the shipped dist/mcp.mjs as a real process, speaking JSON-RPC to it over a pipe, and getting a skill back through the real engine.

Two host facts for the docs, not the code

Found the same way, not addressed here:

  • Hermes caps an external memory provider's prefetch at 8 seconds and does not let the provider raise it, while this plugin offers timeout_s as a user-facing setting. Any value above 8 is a promise that host will not keep: measured, a 17-second retrieval was discarded and auto mode injected nothing.
  • Raven's auto mode depends on a host patch that exists only locally. The [[plugin.contributes.context_segments]] slot this plugin declares is not in Raven on GitLab or GitHub, and no open MR adds it — it is 335 lines of uncommitted local changes. Raven ignores manifest keys it does not know, so on a stock Raven mode: auto is silently inert. On-demand does not depend on the patch.

A default that changed

WorkBuddy's hook used to inject on every turn with no configuration. It now injects only under mode: "auto", so an existing install upgrading to this gets nothing until it either sets that or starts calling skill_search. The same is true of every host, but WorkBuddy is the one where the replacement path — the MCP server — is the newest.

🤖 Generated with Claude Code

Tian-yi-Sun and others added 5 commits September 1, 2026 03:55
Two things that only make sense together: 2.0 needs a differently-shaped
plugin, and both plugins gain a second way to reach the agent.

## Why a second OpenClaw package rather than a change to the first

`plugin-openclaw` injects through the `before_prompt_build` hook. On OpenClaw
2.0 (2026.8.1) that path is dead for a plugin installed from anywhere but an
official source, and dead silently: measured on a real 2.0 install, the
handler is never invoked under `agent --local`, and through the gateway it
runs and has its `prependContext` dropped — with `hooks.allowConversationAccess`,
`hooks.allowPromptInjection` and `--accept-capabilities` all granted. Nothing
is logged either way. Retrieval looks installed and never happens.

2.0's answer is capability registration, so `plugin-openclaw2` registers a
context engine instead. Four undocumented-in-practice requirements had to be
met before the host would call it, each failing silently:

  - `plugins.slots.contextEngine` must name the plugin — the slot is
    exclusive, and registering is not selecting.
  - `info.transcriptSemantics.currentTurnFence` must be declared, or the
    engine is degraded to `legacy` every turn and `assemble` is never called.
  - `turnAdvancementIdempotency` likewise — and the host checks the
    declaration *and* `typeof engine.commitTurn === 'function'` together, so
    it is implemented rather than merely claimed.
  - `contracts.tools` in the manifest, or a registered tool is not offered.

The 1.x plugin is untouched in shape and stays for hosts through 2026.7.x.

What the 2.0 contract gives that the hook did not: `assemble` is handed the
turn's `prompt`, the agent's real `availableTools` — so the gate's
environment check finally runs on OpenClaw — and one engine per workspace,
which is where the PathGuard placeholders' per-agent facts now come from.

The engine reports `ownsCompaction: false` and declines `compact`: it appends
to an assembled context and keeps no transcript, and claiming otherwise would
stop the host compacting a session nobody else is compacting.

## The second mode

`mode` selects how skills reach the agent, defaulting to `on_demand`:

  - `on_demand` registers a `skill_search` tool and lets the agent decide.
    A long task pays for retrieval at the step that needs it and nothing on
    the turns that do not.
  - `auto` is the existing behaviour: search every turn, inject what fits.

They are exclusive rather than additive — on 2.0 because `auto` takes the
exclusive context-engine slot, on 1.x because running both would pay for
retrieval twice and put the same skill in front of the model twice.

`on_demand` is the default because `auto`'s cost is paid continuously and
silently, while its own failure mode — an agent that never thinks to search —
is what the tool description exists to prevent. That description is therefore
load-bearing, and pinned by a test: it must say when to call and what a miss
means.

## Also

`bundleCacheDir` was missing from both manifests, which are
`additionalProperties: false` — so a deployment that set it had its *whole*
plugin config rejected and ran on defaults, silently. Both manifests now
declare it, and a test pins `config.ts` against the manifest in both
directions so the next such key cannot be forgotten.

Verified against a real OpenClaw 2026.8.1: with `mode: auto` the model
answers from facts that exist only in a retrieved skill body, and the host
logs no degradation. Every job green — python 103, no-extras 93, parity 36,
hermes 22, raven 8 (3 skipped), openclaw 41, openclaw2 41, workbuddy 32 —
and both packages pass `verify_npm_package.mjs`.

Not verified: `skill_search` reaching a model on a live host. The tool
registers and its unit tests pass, but no end-to-end run has yet shown an
agent calling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mode switch landed on the two OpenClaw plugins; this takes it to the
rest, and finds the boundary of where it can go.

Hermes, Raven and DeepSeek Harness all gain `mode`, defaulting to
`on_demand`, through each host's own tool surface:

  - Hermes: the memory provider's `get_tool_schemas` / `handle_tool_call`,
    which until now returned `[]` with a comment saying retrieval is
    automatic. It is not, any more.
  - Raven: a second `[[plugin.contributes.tools]]` entry beside the existing
    context segment. Both factories are always declared and each declines
    when it is not the configured mode, which is how a static manifest and a
    runtime setting are reconciled — the host drops a tool whose factory
    returns `None`.
  - DSH: `ctx.tools.register` under a new `tools` injection. `dsh-tools` was
    already a peer dependency.

In every one the auto path goes silent in on-demand mode rather than running
alongside: two live paths would search twice for one turn and put the same
skill in front of the model from two directions.

WorkBuddy does not get it, and cannot as it stands: its plugin is a
`UserPromptSubmit` command hook and the manifest declares only `hooks`.
There is no surface on which to offer a model-callable tool, so that host
stays auto-only.

The tool description earned a clause. On a real OpenClaw 2.0, "I need to
build a quarterly deck, follow our established procedure" made the model
call `skill_search`, while "what is our internal template name for quarterly
decks" did not — it answered that it did not know, from a workspace where
the answer was one search away. The description covered improvising a
procedure but not being asked about an in-house convention, so it now names
that case, and every host's copy pins it in a test.

Verified end to end on OpenClaw 2026.8.1: with the clause added, the same
question that failed now returns facts that exist only in a skill body.

Every job green: python 103, no-extras 93, parity 36, hermes 23,
raven 14 (3 skipped), openclaw 41, openclaw2 41, workbuddy 32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WorkBuddy was the one host left on auto-only, and the earlier commit said it
could not have the other mode. That was wrong: it was true of the plugin's
shape, not of the host.

Its plugin is a `UserPromptSubmit` command hook — the host spawns a process
per turn, feeds it the prompt, reads `additionalContext`, and the process
exits. A hook can inject text but cannot offer something a model chooses to
call, which is why there was no tool to register. But the manifest also
accepts `mcpServers`, which the host merges into its own MCP configuration at
startup, and an MCP tool is a tool. So on-demand mode here is a second entry
point speaking MCP over stdio.

The protocol is written out rather than pulled in. This package has no
dependencies and ships a checked-in bundle built with `--packages=external`,
so an SDK would have to be installed beside that bundle at runtime — which is
the one thing a host-spawned process cannot count on. A one-tool server needs
four messages. The accepted protocol versions were read out of WorkBuddy
2.143.0 rather than guessed; an unrecognised one is answered with the newest
this server speaks, leaving the client to accept or refuse.

The modes stay exclusive, from both ends: the hook does not search when the
mode is not `auto`, and the server exits at startup when it is not
`on_demand`. Two live paths would search twice for one turn.

One difference from every other host: the hook is per-turn and dies, while
this server is long-lived. That favours it — the local scan the hook's disk
cache exists to avoid repeating happens once here.

Found while testing, both by the tests that now cover them: the entry point
started the server at module scope, so anything importing it — a test, or a
tool reading the schema — had its own stdin taken over and never exited; and
the spawned-process test passed while leaving the runner alive on the child's
pipes, which in CI is a timeout rather than a failure.

Green: python 103, hermes 23, raven 14 (3 skipped), parity 36, openclaw 41,
openclaw2 41, workbuddy 43 — and the last of those includes driving the
shipped `dist/mcp.mjs` as a real process over a pipe, through the real engine,
to a retrieved skill.

Not verified: the host actually loading this server. WorkBuddy is not
installable here, so what is proven is the server, not the integration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Driving Hermes, Raven and DeepSeek Harness end to end turned up five defects
in the previous two commits. Every one of them was invisible to the suites
that pass: two hosts are not installable in CI, so the tests that would have
caught them either skip or exercise a factory instead of the thing the host
calls.

**Raven's tool was not a `Tool`.** `SkillSearchTool` was duck-typed against
the ABC on the grounds that "the four members below are that ABC's whole
surface". They are its whole *abstract* surface; `Tool` also carries concrete
implementations the host calls every turn — `to_schema`, `cast_params`,
`validate_params`, `display_call`, `timeout_seconds`, `blocking_interaction`.
On a real Raven the first turn died with `AttributeError: 'SkillSearchTool'
object has no attribute 'to_schema'`, so on-demand mode did not work there at
all. The factory now subclasses the host's own `Tool` around the same body,
importing it at call time so the package still installs and tests without a
checkout.

**Three of Raven's segment tests were red and nobody could see it.** A
`mode: 'auto'` added to their fixture did not survive a `ruff --fix` reflow,
and every one of them is `@needs_host` — skipped everywhere the suite
normally runs. Against a real checkout the baseline passes 11 and this branch
passed 8. Fixed, and the suite is 18 there now.

**DSH accepted a misspelled mode.** `mode: z.string()` with a branch reading
`!== 'auto'` meant `mode: "atuo"` silently became on-demand: the deployment
asks for auto, does not get it, and is told nothing. A union makes the loader
reject it.

**DSH: `exec.agent` is optional and `inject` was a regression.** The tool
contract marks `agent` optional — a non-agent caller has none — and
`toolNames` was called twice per invocation, walking the registry once per
branch of its own ternary. Separately, adding `'tools'` to the module's
`inject` array made an optional service a required one: a composition that
mounts no tool service would have stopped loading the plugin entirely, auto
mode included. The dependency is now scoped with `ctx.inject` to the mode
that needs it.

**The tool description was being flattened.** All four TypeScript copies
joined their lines with `' '`, so the blank strings meant as paragraph breaks
became spaces and the bulleted "reach for it when" list arrived as one
paragraph. The models still called the tool, but not because of the shape the
text was written in.

`engine.ts` also could not compile under the harness's
`exactOptionalPropertyTypes`; the placeholder runtime is now built by
spreading only defined fields, which is also the correct semantics — writing
a key as `undefined` is not the same as leaving it out, and
`resolvePlaceholders` falls back per field on absence.

Verified: `tsc -b` inside a real harness checkout, which no CI job can do
(`@deepseek-ai/*` resolves only there), now passes — it reported three errors
before. Raven's suite against a real checkout is 18/18. Every other job
green: python 103, hermes 23, raven 12 (6 skipped without a checkout),
parity 36, openclaw 41, openclaw2 41, workbuddy 43.

Two host facts found the same way belong in the docs rather than the code,
and are not addressed here:

  - Hermes caps an external memory provider's `prefetch` at 8 seconds and
    does not let the provider raise it, while this plugin offers `timeout_s`
    as a user-facing setting. Any value above 8 is a promise that host will
    not keep: measured, a 17-second retrieval was discarded and auto mode
    injected nothing.
  - Raven's `build_plugin_tools` has no skillsearch special case where
    `build_plugin_segments` does, so in on-demand mode — the default — the
    rewriter and the gate get no model channel. `_provider` is a private key
    a user cannot set from TOML, so a deployment cannot work around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On-demand is the default, and on Raven it ran with no rewriter and no gate.

Raven hands the *segment* factory a live `LLMProvider` under a private
`_provider` key, and that was the only channel this plugin knew. The *tool*
factory is handed no such thing — `build_plugin_tools` passes the config
slice and a `ServiceLocator`, nothing more — and a live object cannot be
written in TOML, so no deployment could supply one.

Unfiltered retrieval is not a mild degradation. Fusion ranks by position, so
every source's best hit reaches the model however weakly it matched, and the
gate is the only step that removes those.

So the plugin now carries the fallback the other four host plugins already
had: an OpenAI-compatible client named by ordinary config — `model`,
`model_base_url`, `model_api_key`, `model_timeout_s` — used when `_provider`
is absent. The host's own provider still wins where it is offered; it is the
model the user picked and it follows a `/model` switch.

A copy of the Hermes client rather than a shared engine module, matching how
`model.ts` sits inside each TypeScript plugin: the engine deliberately owns
no model client, because which endpoint to call is a property of the host's
deployment rather than of retrieval. It depends on nothing beyond `urllib`.

The keys are declared in `raven-plugin.toml`, which is load-bearing here:
Raven drops config keys its manifest does not name, so an undeclared setting
is silently ignored rather than rejected.

Verified end to end against a real Raven, with on-demand given **no**
`_provider` — the path the fix exists for. Both modes pass: the tool is
offered and the segment declines, the model calls `skill_search` itself, and
the reply carries facts that exist only in the retrieved skill body. The
suite is 22/22 against a checkout and 16 (6 skipped) without one.

The harness also stopped lying about the deployment: its corpus now lives
inside the workspace. Outside it, the agent checked, found the skill
directory absent, and refused to trust the block it had just been handed —
a harness artefact that read as a retrieval failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant