From 08cc44b57d3f7c31483e1c8d103589b1b58c30de Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 28 Apr 2026 03:47:04 +0100 Subject: [PATCH] feat: lazily load tool result middleware plugins --- CHANGELOG.md | 1 + .../codex-app-server.extensions.test.ts | 52 +++++++++++ src/agents/harness/tool-result-middleware.ts | 20 +++- src/agents/pi-embedded-runner/extensions.ts | 7 +- .../agent-tool-result-middleware-loader.ts | 92 +++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 src/plugins/agent-tool-result-middleware-loader.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6f7639d58..aeab4aef004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay. - Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh. - Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd. +- Plugins/runtime: load bundled agent tool-result middleware from manifest contracts on demand so tokenjuice stays startup-lazy without losing Pi/Codex tool-output compaction. Thanks @shakkernerd. - Plugins/startup: add explicit `activation.onStartup` metadata so plugins can declare Gateway startup import behavior while the deprecated implicit sidecar fallback remains for legacy plugins. Thanks @shakkernerd. - Gateway/startup: reuse lookup-table plugin manifests when loading startup plugins so Gateway boot avoids rebuilding plugin discovery and manifest metadata. Thanks @shakkernerd. - CLI/models: declare fixed Qianfan, Xiaomi, NVIDIA, Cerebras, and Mistral model catalogs in plugin manifests so provider-filtered model listing can use the manifest fast path without loading provider runtime catalog code. Thanks @shakkernerd. diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts index 819adc4f4c2..b2348565c5e 100644 --- a/src/agents/codex-app-server.extensions.test.ts +++ b/src/agents/codex-app-server.extensions.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import { createAgentToolResultMiddlewareRunner, createCodexAppServerToolResultExtensionRunner, @@ -21,6 +22,7 @@ function createTempDir(): string { } afterEach(() => { + clearRuntimeConfigSnapshot(); cleanupTempPluginTestEnvironment(tempDirs, originalBundledPluginsDir); }); @@ -190,6 +192,56 @@ export default { id: "tool-result-middleware", register(api) { expect(listAgentToolResultMiddlewares("pi")).toHaveLength(1); expect(listAgentToolResultMiddlewares("codex")).toHaveLength(1); }); + + it("lazily loads bundled middleware owners from manifest contracts", async () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "tool-result-middleware", + filename: "index.mjs", + manifest: { + activation: { + onStartup: false, + }, + contracts: { + agentToolResultMiddleware: ["codex"], + }, + }, + body: `export default { id: "tool-result-middleware", register(api) { + api.registerAgentToolResultMiddleware(async (event) => ({ + result: { ...event.result, content: [{ type: "text", text: event.toolName + " lazily compacted" }] } + }), { runtimes: ["codex"] }); +} };`, + }); + + setRuntimeConfigSnapshot({ + plugins: { + entries: { + "tool-result-middleware": { + enabled: true, + }, + }, + }, + }); + resetActivePluginRegistryForTest(); + + expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0); + + const runner = createAgentToolResultMiddlewareRunner({ runtime: "codex" }); + const result = await runner.applyToolResultMiddleware({ + threadId: "thread-1", + turnId: "turn-1", + toolCallId: "call-1", + toolName: "exec", + args: { command: "git status" }, + result: { content: [{ type: "text", text: "raw" }], details: {} }, + }); + + expect(result.content).toEqual([{ type: "text", text: "exec lazily compacted" }]); + expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0); + }); }); describe("Codex app-server extension factories", () => { diff --git a/src/agents/harness/tool-result-middleware.ts b/src/agents/harness/tool-result-middleware.ts index 9611f88def2..a24cbed75f2 100644 --- a/src/agents/harness/tool-result-middleware.ts +++ b/src/agents/harness/tool-result-middleware.ts @@ -5,7 +5,6 @@ import type { AgentToolResultMiddlewareEvent, OpenClawAgentToolResult, } from "../../plugins/agent-tool-result-middleware-types.js"; -import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; import { truncateUtf16Safe } from "../../utils.js"; const log = createSubsystemLogger("agents/harness"); @@ -122,15 +121,30 @@ function buildMiddlewareFailureResult(): OpenClawAgentToolResult { export function createAgentToolResultMiddlewareRunner( ctx: AgentToolResultMiddlewareContext, - handlers: AgentToolResultMiddleware[] = listAgentToolResultMiddlewares(ctx.runtime), + handlers?: AgentToolResultMiddleware[], ) { const middlewareContext = { ...ctx, harness: ctx.harness ?? ctx.runtime }; + let resolvedHandlers = handlers; + let resolvedHandlersPromise: Promise | undefined; + const resolveHandlers = async (): Promise => { + if (resolvedHandlers) { + return resolvedHandlers; + } + resolvedHandlersPromise ??= import("../../plugins/agent-tool-result-middleware-loader.js").then( + ({ loadAgentToolResultMiddlewaresForRuntime }) => + loadAgentToolResultMiddlewaresForRuntime({ + runtime: ctx.runtime, + }), + ); + resolvedHandlers = await resolvedHandlersPromise; + return resolvedHandlers; + }; return { async applyToolResultMiddleware( event: AgentToolResultMiddlewareEvent, ): Promise { let current = event.result; - for (const handler of handlers) { + for (const handler of await resolveHandlers()) { try { const next = await handler({ ...event, result: current }, middlewareContext); // Middleware may mutate event.result in place for legacy Pi parity. diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 0603e989bf5..c639d58ea14 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ExtensionFactory, SessionManager } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { resolveContextWindowInfo } from "../context-window-guard.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; @@ -35,13 +34,9 @@ function recordFromUnknown(value: unknown): Record { } function buildAgentToolResultMiddlewareFactory(): ExtensionFactory { - const handlers = listAgentToolResultMiddlewares("pi"); - const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }, handlers); + const runner = createAgentToolResultMiddlewareRunner({ runtime: "pi" }); return (pi) => { pi.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => { - if (handlers.length === 0) { - return undefined; - } const event = recordFromUnknown(rawEvent) as PiToolResultEvent; if (!event.toolName) { return undefined; diff --git a/src/plugins/agent-tool-result-middleware-loader.ts b/src/plugins/agent-tool-result-middleware-loader.ts new file mode 100644 index 00000000000..f567f9c3697 --- /dev/null +++ b/src/plugins/agent-tool-result-middleware-loader.ts @@ -0,0 +1,92 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareRuntime, +} from "./agent-tool-result-middleware-types.js"; +import { + listAgentToolResultMiddlewares, + normalizeAgentToolResultMiddlewareRuntimeIds, +} from "./agent-tool-result-middleware.js"; +import { loadOpenClawPlugins } from "./loader.js"; +import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; + +const log = createSubsystemLogger("plugins/agent-tool-result-middleware"); + +async function resolveRuntimeConfig(): Promise { + const { getRuntimeConfig } = await import("../config/config.js"); + return getRuntimeConfig(); +} + +function listMiddlewareOwnerPluginIds(params: { + manifestRegistry: PluginManifestRegistry; + runtime: AgentToolResultMiddlewareRuntime; +}): string[] { + const pluginIds: string[] = []; + for (const record of params.manifestRegistry.plugins) { + if (record.origin !== "bundled") { + continue; + } + const runtimes = normalizeAgentToolResultMiddlewareRuntimeIds( + record.contracts?.agentToolResultMiddleware, + ); + if (runtimes.includes(params.runtime) && !pluginIds.includes(record.id)) { + pluginIds.push(record.id); + } + } + return pluginIds; +} + +export async function loadAgentToolResultMiddlewaresForRuntime(params: { + runtime: AgentToolResultMiddlewareRuntime; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + manifestRegistry?: PluginManifestRegistry; +}): Promise { + const activeHandlers = listAgentToolResultMiddlewares(params.runtime); + if (activeHandlers.length > 0) { + return activeHandlers; + } + + try { + const config = params.config ?? (await resolveRuntimeConfig()); + const env = params.env ?? process.env; + const manifestRegistry = + params.manifestRegistry ?? + loadPluginManifestRegistry({ + config, + workspaceDir: params.workspaceDir, + env, + }); + const pluginIds = listMiddlewareOwnerPluginIds({ + manifestRegistry, + runtime: params.runtime, + }); + if (pluginIds.length === 0) { + return []; + } + + const registry = loadOpenClawPlugins({ + config, + workspaceDir: params.workspaceDir, + env, + manifestRegistry, + onlyPluginIds: pluginIds, + activate: false, + throwOnLoadError: false, + }); + + return registry.agentToolResultMiddlewares + .filter((entry) => entry.runtimes.includes(params.runtime)) + .map((entry) => entry.handler); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log.warn(`[${params.runtime}] failed to load tool result middleware plugins: ${detail}`); + return listAgentToolResultMiddlewares(params.runtime); + } +} + +export const __testing = { + listMiddlewareOwnerPluginIds, +};