diff --git a/CHANGELOG.md b/CHANGELOG.md index 74953047f..12a1a9063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index c6eb54b05..9ef2a3efe 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -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( "../../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; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ad5cecd8b..91f99571d 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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 { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c96089a9f..21b29fe2c 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index f4d6f5cbe..48cb586e7 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -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, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts new file mode 100644 index 000000000..ace53258e --- /dev/null +++ b/src/agents/runtime-plugins.ts @@ -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, + }); +} diff --git a/src/agents/subagent-registry.context-engine.test.ts b/src/agents/subagent-registry.context-engine.test.ts new file mode 100644 index 000000000..59eea1bd4 --- /dev/null +++ b/src/agents/subagent-registry.context-engine.test.ts @@ -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("../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) => 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", + }); + }); +}); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index e2453bcc0..9ef58933f 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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); diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index a97ed7807..a153ddbad 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -13,6 +13,7 @@ export type SubagentRunRecord = { cleanup: "delete" | "keep"; label?: string; model?: string; + workspaceDir?: string; runTimeoutSeconds?: number; spawnMode?: SpawnSubagentMode; createdAt: number; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 8f7c41866..f2a635521 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -650,6 +650,7 @@ export async function spawnSubagentDirect( cleanup, label: label || undefined, model: resolvedModel, + workspaceDir: spawnedMetadata.workspaceDir, runTimeoutSeconds, expectsCompletionMessage, spawnMode,