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
Draft
feat(plugin): an OpenClaw 2.0 plugin, and on-demand retrieval on every host#15Tian-yi-Sun wants to merge 5 commits into
Tian-yi-Sun wants to merge 5 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an OpenClaw 2.0 plugin, and a second retrieval mode across all six hosts.
The second mode
modeselects how skills reach the agent, defaulting toon_demand:on_demandoffers askill_searchtool 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.autois 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.
api.registerToolget_tool_schemas/handle_tool_call[[plugin.contributes.tools]]entry; each factory declines when it is not the configured modectx.tools.register, scoped withctx.injectmcpServerson_demandis the default becauseauto'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-openclawinjects through thebefore_prompt_buildhook. 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 underagent --local, and through the gateway it runs and has itsprependContextdropped — withhooks.allowConversationAccess,hooks.allowPromptInjectionand--accept-capabilitiesall granted. Nothing is logged either way.plugin-openclaw2registers a context engine instead. Four requirements had to be met before the host would call it, each failing silently:plugins.slots.contextEnginenames the plugininfo.transcriptSemantics.currentTurnFencelegacyevery turn;assemblenever calledturnAdvancementIdempotencyand a realcommitTurncontracts.toolsin the manifestThe 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:
assemblereceives the turn'sprompt, the agent's realavailableTools— so the gate's environment check finally runs on OpenClaw — and one engine per workspace. The engine reportsownsCompaction: falseand declinescompact: 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_skillanduse_skill. Both are downstream of always-on injection: the# Skillscatalog still lands every turn, and the tools let the model pull one candidate's full body, or drop itsscripts/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 -Sover this repository's whole history and over the oldskillsearch_pluginsfinds noskill_searchand noon_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.
Tool— duck-typed against the ABC, so the first real turn died onto_schema@needs_host, so skipped everywhere the suite normally runsz.string()turnedmode: "atuo"into a silent downgradeexec.agentis optional, andtoolNameswalked the registry twice per call'tools'toinjectmade an optional service required, so a composition without one would stop loading the plugin entirely — auto mode included' ', flattening its paragraph breaks and bulleted list into one line — in all four TypeScript copiesTwo more were fixed in passing:
engine.tscould not compile under the harness'sexactOptionalPropertyTypes, andbundleCacheDirwas missing from both OpenClaw manifests — which areadditionalProperties: false, so a deployment that set it had its whole config rejected and ran on defaults, silently. A test now pinsconfig.tsagainst the manifest in both directions.Raven's on-demand mode had no model
build_plugin_toolspasses a factory the config slice and aServiceLocatorand nothing else, so the liveLLMProviderthe 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:
skill_search?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 -binside 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:
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 43includes spawning the shippeddist/mcp.mjsas 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:
prefetchat 8 seconds and does not let the provider raise it, while this plugin offerstimeout_sas 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.automode 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 Ravenmode: autois 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 callingskill_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