fix(agents): bootstrap runtime plugins before context-engine resolution

This commit is contained in:
Peter Steinberger
2026-03-08 23:38:14 +00:00
parent 936ac22ec2
commit 661af2acd3
10 changed files with 196 additions and 1 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
<<<<<<< HEAD
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
@@ -44,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
## 2026.3.7

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
hookRunner,
ensureRuntimePluginsLoaded,
resolveModelMock,
sessionCompactImpl,
triggerInternalHook,
@@ -12,6 +13,7 @@ const {
runBeforeCompaction: vi.fn(),
runAfterCompaction: vi.fn(),
},
ensureRuntimePluginsLoaded: vi.fn(),
resolveModelMock: vi.fn(() => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
@@ -32,6 +34,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookRunner,
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded,
}));
vi.mock("../../hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
@@ -254,6 +260,7 @@ const sessionHook = (action: string) =>
describe("compactEmbeddedPiSessionDirect hooks", () => {
beforeEach(() => {
ensureRuntimePluginsLoaded.mockReset();
triggerInternalHook.mockClear();
hookRunner.hasHooks.mockReset();
hookRunner.runBeforeCompaction.mockReset();
@@ -279,6 +286,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
});
it("bootstraps runtime plugins with the resolved workspace", async () => {
await compactEmbeddedPiSessionDirect({
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
});
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
});
});
it("emits internal + plugin compaction hooks with counts", async () => {
hookRunner.hasHooks.mockReturnValue(true);
let sanitizedCount = 0;

View File

@@ -50,6 +50,7 @@ import {
} from "../pi-embedded-helpers.js";
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { resolveSandboxContext } from "../sandbox.js";
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
@@ -269,6 +270,10 @@ export async function compactEmbeddedPiSessionDirect(
const maxAttempts = params.maxAttempts ?? 1;
const runId = params.runId ?? params.sessionId;
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
});
const prevCwd = process.cwd();
// Resolve compaction model: prefer config override, then fall back to caller-supplied model
@@ -910,6 +915,10 @@ export async function compactEmbeddedPiSession(
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: params.workspaceDir,
});
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
try {

View File

@@ -54,6 +54,7 @@ import {
pickFallbackThinkingLevel,
type FailoverReason,
} from "../pi-embedded-helpers.js";
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
@@ -287,6 +288,10 @@ export async function runEmbeddedPiAgent(
`[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
);
}
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
});
const prevCwd = process.cwd();
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;

View File

@@ -1,5 +1,14 @@
import "./run.overflow-compaction.mocks.shared.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runtimePluginMocks = vi.hoisted(() => ({
ensureRuntimePluginsLoaded: vi.fn(),
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
}));
import { runEmbeddedPiAgent } from "./run.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
@@ -10,6 +19,32 @@ describe("runEmbeddedPiAgent usage reporting", () => {
vi.clearAllMocks();
});
it("bootstraps runtime plugins with the resolved workspace before running", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,
promptError: null,
timedOut: false,
sessionIdUsed: "test-session",
assistantTexts: ["Response 1"],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
await runEmbeddedPiAgent({
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-plugin-bootstrap",
});
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
});
});
it("forwards sender identity fields into embedded attempts", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,

View File

@@ -0,0 +1,18 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { resolveUserPath } from "../utils.js";
export function ensureRuntimePluginsLoaded(params: {
config?: OpenClawConfig;
workspaceDir?: string | null;
}): void {
const workspaceDir =
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
? resolveUserPath(params.workspaceDir)
: undefined;
loadOpenClawPlugins({
config: params.config,
workspaceDir,
});
}

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
ensureRuntimePluginsLoaded: vi.fn(),
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(),
onSubagentEnded: vi.fn(async () => {}),
onAgentEvent: vi.fn(() => () => {}),
persistSubagentRunsToDisk: vi.fn(),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: vi.fn(() => ({})),
};
});
vi.mock("../context-engine/init.js", () => ({
ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized,
}));
vi.mock("../context-engine/registry.js", () => ({
resolveContextEngine: mocks.resolveContextEngine,
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: mocks.onAgentEvent,
}));
vi.mock("./runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded,
}));
vi.mock("./subagent-registry-state.js", () => ({
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
restoreSubagentRunsFromDisk: vi.fn(() => 0),
}));
vi.mock("./subagent-announce-queue.js", () => ({
resetAnnounceQueuesForTests: vi.fn(),
}));
vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 1_000),
}));
import {
registerSubagentRun,
releaseSubagentRun,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
describe("subagent-registry context-engine bootstrap", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveContextEngine.mockResolvedValue({
onSubagentEnded: mocks.onSubagentEnded,
});
resetSubagentRegistryForTests({ persist: false });
});
it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => {
registerSubagentRun({
runId: "run-1",
childSessionKey: "agent:main:session:child",
requesterSessionKey: "agent:main:session:parent",
requesterDisplayKey: "parent",
task: "task",
cleanup: "keep",
workspaceDir: "/tmp/workspace",
});
releaseSubagentRun("run-1");
await vi.waitFor(() => {
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
});
});
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
childSessionKey: "agent:main:session:child",
reason: "released",
workspaceDir: "/tmp/workspace",
});
});
});

View File

@@ -16,6 +16,7 @@ import { onAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { defaultRuntime } from "../runtime.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
import {
captureSubagentCompletionReply,
@@ -313,10 +314,16 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number;
async function notifyContextEngineSubagentEnded(params: {
childSessionKey: string;
reason: SubagentEndReason;
workspaceDir?: string;
}) {
try {
const cfg = loadConfig();
ensureRuntimePluginsLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
});
ensureContextEnginesInitialized();
const engine = await resolveContextEngine(loadConfig());
const engine = await resolveContextEngine(cfg);
if (!engine.onSubagentEnded) {
return;
}
@@ -714,6 +721,7 @@ async function sweepSubagentRuns() {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "swept",
workspaceDir: entry.workspaceDir,
});
subagentRuns.delete(runId);
mutated = true;
@@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "deleted",
workspaceDir: params.entry.workspaceDir,
});
subagentRuns.delete(params.runId);
persistSubagentRuns();
@@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "completed",
workspaceDir: params.entry.workspaceDir,
});
params.entry.cleanupCompletedAt = params.completedAt;
persistSubagentRuns();
@@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
expectsCompletionMessage?: boolean;
spawnMode?: "run" | "session";
@@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: {
spawnMode,
label: params.label,
model: params.model,
workspaceDir: params.workspaceDir,
runTimeoutSeconds,
createdAt: now,
startedAt: now,
@@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "released",
workspaceDir: entry.workspaceDir,
});
}
const didDelete = subagentRuns.delete(runId);

View File

@@ -13,6 +13,7 @@ export type SubagentRunRecord = {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
spawnMode?: SpawnSubagentMode;
createdAt: number;

View File

@@ -650,6 +650,7 @@ export async function spawnSubagentDirect(
cleanup,
label: label || undefined,
model: resolvedModel,
workspaceDir: spawnedMetadata.workspaceDir,
runTimeoutSeconds,
expectsCompletionMessage,
spawnMode,