Skip to content

[Show and Tell] canon-guardian: selective workspace canon re-injection on model switch / native fallback / restart #75750

Description

@smonett

[Show and Tell] canon-guardian: selective workspace canon re-injection on model switch / native fallback / restart

Author: @smonett (intensive daily user since 2026.2)
Audited against: OpenClaw 2026.4.27 (verified against dist/ package internals — line refs below)
Companion to: #65824 item 1 ("Silent model fallback + no system-prompt re-injection")
Status: Working in production. Sharing as a reference implementation, not a feature ask.


The problem

Long-lived OpenClaw sessions running on agents.defaults.contextInjection: "continuation-skip" skip re-injecting workspace bootstrap (AGENTS.md, SOUL.md, etc.) on every turn — saves a lot of tokens.

The trade-off shows up during native model fallback: when the primary 402s / rate-limits and the fallback chain fires, the fallback model inherits the conversation transcript but not the workspace bootstrap. Same for manual /model switches mid-session, and for sessions that drift past the 6+ hour mark where we'd want a sanity refresh.

Result: the model-that-actually-answers wakes up partially blind to operating procedures and persona — the "session suddenly feels different" failure mode. (We filed this as #65824 item 1; this plugin is our local mitigation while we wait on a native solution.)

What canon-guardian does

A small native plugin that re-injects the workspace canon only on events that justify it:

Trigger Reason string Detection
New session (first turn) new-session no prior state for sessionId
Model switch (manual or fallback) model-switch ${providerId}:${modelId} changed since last turn
Long idle / restart restart-or-idle now - lastInjectMs > restartIdleMs (default 6h)
Periodic safety net periodic-50 turnCount % 50 === 0

If none of those fire, the hook returns nothing. On a quiet turn the plugin's overhead is one Map lookup.

Why we picked before_prompt_build

We tried llm_input first based on outside advice. That was wrong, and the cost was a couple of bad turns where we thought it was working but our fallback model was still booting blind. Receipts:

  • dist/hook-runner-global-BmRBtwY4.js lines ~140-160 — mergeBeforePromptBuild is the only merge function that exposes prependSystemContext, the field that ends up in the cache-eligible prefix of the system prompt.
  • dist/status-DjPbn_Lx.js line ~111 — runtime explicitly warns plugin authors: "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve / before_prompt_build for new work." That's the maintainer-blessed direction; we followed it.
  • dist/extensions/active-memory/index.js line ~1190 — the first-party active-memory extension uses before_prompt_build with the same try/catch shape. Same pattern, same hook, validated upstream.

Sharing the misstep here so the next plugin author doesn't repeat it. If there's appetite for a docs note explicitly steering people away from llm_input for system-prompt mutation, happy to PR one.

Multi-model behavior (audited)

  • dist/lifecycle-hook-helpers-g--XRrcl.js lines 4-18 — buildAgentHookContext populates modelProviderId + modelId provider-agnostically. Every provider flows through the same builder.
  • The plugin keys on the composite ${providerId}:${modelId}, so all the following count as a model-switch event:
    • Anthropic ↔ xAI / Grok
    • Anthropic ↔ Google / Gemini
    • Anthropic ↔ OpenAI
    • Same provider, different tier (e.g. anthropic:claude-opus-4-7anthropic:claude-haiku-4-5)
  • On native fallback specifically: the hook context reflects the model that's about to answer, not the originally-configured primary. That's the behavior we want — re-inject for whoever is on the call.

How it composes with other plugins

mergeBeforePromptBuild (same file as above) concatenates prependSystemContext segments from every registered hook rather than overwriting. So canon-guardian composes cleanly with active-memory and any other plugin that uses the same hook surface — order is registration order, separator is the runtime's concatOptionalTextSegments joiner.

When this beats contextInjection: "always"

contextInjection: "always" is the brute-force alternative: re-inject canon on every turn. That works but pays the canon's input-token cost on every turn. On a 50KB canon × 200-turn session at Opus rates, the difference is measurable.

canon-guardian re-injects on roughly 2–4% of turns in our usage (new session + occasional fallback + periodic safety net). Same correctness, fraction of the cost.

If your sessions are short or your canon is small, contextInjection: "always" is the simpler answer and we'd recommend that first.

What 4.27 already gets right

  • buildFallbackNotice emits ↪️ Model Fallback: <active> lifecycle events on transition (visible to verbose users)
  • The before_prompt_build contract is well-defined and stable; the mergeBeforePromptBuild semantics (concat, not overwrite) make plugin composition safe by design
  • Default hook timeout (DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK.before_prompt_build = 15000) is generous enough for canon load even on cold cache

The canon-guardian plugin slots in cleanly because the platform already gave us the right shape to slot into.

Code

Two files. ~140 LOC total.

~/.openclaw/plugins/canon-guardian/openclaw.plugin.json:

{
  "id": "canon-guardian",
  "name": "Canon Guardian",
  "version": "1.0.0",
  "description": "Selective canon re-injection on model switch, new session, gateway restart, and periodic intervals.",
  "main": "./index.js",
  "type": "module"
}

~/.openclaw/plugins/canon-guardian/index.js:

// Canon Guardian — selective workspace canon re-injection for OpenClaw 2026.4.x
//
// Hook: `before_prompt_build` — returns `prependSystemContext` so the canon
// stays in the cache-eligible portion of the system prompt.
//
// State: in-memory Map keyed by sessionId. Clears on gateway restart, which
// is intentional — the next turn after restart re-injects.

import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { promises as fs } from "node:fs";
import path from "node:path";

const DEFAULTS = {
  canonFiles: ["AGENTS.md"],
  injectionInterval: 50,
  restartIdleMs: 6 * 60 * 60 * 1000,
  logInjections: true,
};

function readConfig(api) {
  const cfg = api.pluginConfig || {};
  return {
    canonFiles: Array.isArray(cfg.canonFiles) && cfg.canonFiles.length
      ? cfg.canonFiles : DEFAULTS.canonFiles,
    injectionInterval: typeof cfg.injectionInterval === "number"
      ? cfg.injectionInterval : DEFAULTS.injectionInterval,
    restartIdleMs: typeof cfg.restartIdleMs === "number"
      ? cfg.restartIdleMs : DEFAULTS.restartIdleMs,
    logInjections: typeof cfg.logInjections === "boolean"
      ? cfg.logInjections : DEFAULTS.logInjections,
  };
}

export default definePluginEntry({
  id: "canon-guardian",
  name: "Canon Guardian",
  description:
    "Selective workspace canon re-injection on model switch, new session, gateway restart, and periodic intervals.",
  register(api) {
    if (api.registrationMode !== "full") return;

    const cfg = readConfig(api);
    const log = api.logger;

    // Workspace-keyed cache so multi-agent gateways do not cross-pollinate.
    const canonCache = new Map();
    // sessionId -> { lastModelKey, turnCount, lastInjectMs }
    const sessionState = new Map();

    async function loadCanon(workspaceDir) {
      if (!workspaceDir) return "";
      const cached = canonCache.get(workspaceDir);
      if (cached) return cached;

      const parts = await Promise.all(
        cfg.canonFiles.map(async (rel) => {
          const safeRel = String(rel).replace(/^[/\\]+/, "");
          const full = path.join(workspaceDir, safeRel);
          if (!full.startsWith(workspaceDir)) {
            return `<!-- canon-guardian: refused unsafe path "${rel}" -->`;
          }
          try {
            const text = await fs.readFile(full, "utf8");
            return `<!-- canon-guardian: ${safeRel} -->\n${text}`;
          } catch {
            return `<!-- canon-guardian: ${safeRel} not found -->`;
          }
        }),
      );
      const joined = parts.join("\n\n---\n\n");
      canonCache.set(workspaceDir, joined);
      return joined;
    }

    api.on("before_prompt_build", async (_event, ctx) => {
      try {
        const sessionId = ctx?.sessionId;
        const modelId = ctx?.modelId || "unknown";
        const providerId = ctx?.modelProviderId || "unknown";
        const workspaceDir = ctx?.workspaceDir;
        if (!sessionId || !workspaceDir) return;

        const modelKey = `${providerId}:${modelId}`;
        const now = Date.now();
        const prev = sessionState.get(sessionId);
        const turnCount = (prev?.turnCount || 0) + 1;

        const isNewSession = !prev;
        const modelSwitched = !!prev && prev.lastModelKey !== modelKey;
        const periodic =
          turnCount > 1 &&
          cfg.injectionInterval > 0 &&
          turnCount % cfg.injectionInterval === 0;
        const restartDetected =
          !!prev &&
          cfg.restartIdleMs > 0 &&
          now - (prev.lastInjectMs || 0) > cfg.restartIdleMs;

        const reason = isNewSession
          ? "new-session"
          : modelSwitched
            ? "model-switch"
            : restartDetected
              ? "restart-or-idle"
              : periodic
                ? `periodic-${cfg.injectionInterval}`
                : null;

        sessionState.set(sessionId, {
          lastModelKey: modelKey,
          turnCount,
          lastInjectMs: reason ? now : prev?.lastInjectMs || 0,
        });

        if (!reason) return;

        const canon = await loadCanon(workspaceDir);
        if (!canon) return;

        if (cfg.logInjections) {
          log.info(
            `[canon-guardian] re-injecting canon (reason=${reason}, turn=${turnCount}, model=${modelKey})`,
          );
        }

        return { prependSystemContext: canon };
      } catch (err) {
        log.error(`[canon-guardian] hook error: ${err?.message || String(err)}`);
        // Fail gracefully: return nothing (no injection) rather than crash the build.
      }
    });

    log.info("[canon-guardian] loaded");
  },
});

Configuration

Add to openclaw.json:

{
  "plugins": {
    "allow": ["canon-guardian"],
    "search": ["/Users/<you>/.openclaw/plugins/canon-guardian"],
    "config": {
      "canon-guardian": {
        "canonFiles": ["AGENTS.md", "SOUL.md", "OPERATING_RULES.md"],
        "injectionInterval": 50,
        "restartIdleMs": 21600000,
        "logInjections": true
      }
    }
  }
}

Restart the gateway. Look for [canon-guardian] loaded on startup, and [canon-guardian] re-injecting canon (reason=...) on the first turn of each session.

Sample output during a primary→fallback transition:

[canon-guardian] re-injecting canon (reason=new-session, turn=1, model=anthropic:claude-opus-4-7)
↪️ Model Fallback: claude-haiku-4-5
[canon-guardian] re-injecting canon (reason=model-switch, turn=2, model=anthropic:claude-haiku-4-5)

Operational notes

  • Cache friendliness: prepending via prependSystemContext puts canon at the head of the system prompt, where Anthropic / OpenAI / Google all cache stable prefixes. Re-inject turns are roughly one cache-write, not a full re-encode. (Anthropic specifically: stable system-prompt prefix → 90% cost reduction on cached tokens.)
  • Workspace path safety: rejects paths that escape workspaceDir via prefix check; symlinks not followed beyond the workspace root.
  • Failure isolation: entire hook body in try/catch; on error logs and returns no injection rather than crashing the prompt build. Same shape as active-memory.
  • Memory profile: per-session state is ~50 bytes; canon cache is one entry per workspace and is invalidated on gateway restart.
  • Composition: prependSystemContext from multiple plugins is concatenated (mergeBeforePromptBuild), not overwritten. Safe to run alongside active-memory or other prepend-context plugins.

Acknowledged trade-offs

  • In-memory state. Survives across turns within a process, dies on gateway restart. That's intentional but it does mean the first turn after a restart re-injects even on a continuation — by design, but worth flagging.
  • Periodic safety net. Default 50 turns is empirical, not theoretical. If you've never seen drift in a long session, you can disable periodic by setting injectionInterval: 0.
  • Workspace-dir cache. Caches canon file content in memory; if you vi AGENTS.md mid-session the change isn't picked up until restart. Acceptable for our usage; trivially defeatable by adding an mtime check if needed.

Questions for the maintainers

  1. Is there appetite for canon re-injection becoming a native option on fallback? Something like agents.defaults.fallback.reinjectBootstrap: true would obsolete this plugin and we'd happily retire it. (Captured in [Meta] Feature request bundle: 11 platform gaps from intensive daily use #65824 item 1.)
  2. Would a docs note explicitly steering people away from llm_input for system-prompt mutation be welcome? Happy to draft a PR if so. The runtime warning on before_agent_start is excellent; an analogous "this is the right hook for X" note in plugin docs would have saved us the misstep.

— Posted from a 2026.4.27 install on macOS arm64. Thanks to the maintainers for the clean hook surface — once we found before_prompt_build, the rest fell out of the design.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions