diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c74d94e26..53377018e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Breaking + +- Plugin SDK/tool-result transforms: deprecate the Pi-only `api.registerEmbeddedExtensionFactory(...)` path for tool-result rewriting in favor of `api.registerAgentToolResultMiddleware(...)`, with `contracts.agentToolResultMiddleware` declaring the targeted harnesses. The legacy Pi hook remains wired as a bundled compatibility seam, but new plugins should use the harness-neutral middleware contract so transforms run consistently across Pi and Codex app-server dynamic tools. Thanks @vincentkoc. + ### Changes - Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare. @@ -3208,7 +3212,7 @@ Docs: https://docs.openclaw.ai - Telegram/send retry classification: retry grammY `Network request ... failed after N attempts` envelopes in send flows without reclassifying plain `Network request ... failed!` wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023. - Gateway/probes: keep `/health`, `/healthz`, `/ready`, and `/readyz` reachable when the Control UI is mounted at `/`, preserve plugin-owned route precedence on those paths, and make `/ready` and `/readyz` report channel-backed readiness with startup grace plus `503` on disconnected managed channels, while `/health` and `/healthz` stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc. - Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level `httpTimeoutMs` applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow. -- PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei. +- Pi embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei. - Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so `openclaw agent --json` no longer crashes when provider payloads omit `totalTokens` or related usage fields. (#34977) thanks @sp-hk2ldn. - Venice/default model refresh: switch the built-in Venice default to `kimi-k2-5`, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc. - Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect `429`/`Retry-After`. Thanks @vincentkoc. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8bd817b5a35..4542180de21 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -c4a62f081d0b9fcfd5e76a843547411bba0fdc129c1c143e7f4c4f6294b040b9 plugin-sdk-api-baseline.json -a62c9aea45d5694a851380ff6b35b7fb2ffd9fc4dfa3f0c567a8e1c97094475e plugin-sdk-api-baseline.jsonl +b758a1c5503c08325113e0d6c9f1ac2db5a5fd9992a3902706ebe0f0dbbc1213 plugin-sdk-api-baseline.json +2c9d0a00e526dcd47d131261b8ceddd8e59faa8530b129d108a3721a4cbcbea7 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index e4eb4d4de0a..c69890d286e 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -160,7 +160,7 @@ A single plugin can register any number of capabilities via the `api` object: | Video generation | `api.registerVideoGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) | | Web fetch | `api.registerWebFetchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) | | Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) | -| Embedded Pi extension | `api.registerEmbeddedExtensionFactory(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) | +| Tool-result middleware | `api.registerAgentToolResultMiddleware(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) | | Agent tools | `api.registerTool(...)` | Below | | Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) | | Plugin hooks | `api.on(...)` | [Plugin hooks](/plugins/hooks) | @@ -170,10 +170,11 @@ A single plugin can register any number of capabilities via the `api` object: For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api). -Use `api.registerEmbeddedExtensionFactory(...)` when a plugin needs Pi-native -embedded-runner hooks such as async `tool_result` rewriting before the final -tool result message is emitted. Prefer regular OpenClaw plugin hooks when the -work does not need Pi extension timing. +Use `api.registerAgentToolResultMiddleware(...)` when a plugin needs async +tool-result rewriting before the model sees the output. Declare the targeted +harnesses in `contracts.agentToolResultMiddleware`, for example +`["pi", "codex-app-server"]`. Prefer regular OpenClaw plugin hooks when the +work does not need pre-model tool-result timing. If your plugin registers custom gateway RPC methods, keep them on a plugin-specific prefix. Core admin namespaces (`config.*`, diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index b7612865448..aa19074d719 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -25,11 +25,11 @@ These are in-process OpenClaw hooks, not Codex `hooks.json` command hooks: - `before_message_write` for mirrored transcript records - `agent_end` -Bundled plugins can also register a Codex app-server extension factory to add -async `tool_result` middleware. That middleware runs for OpenClaw dynamic tools -after OpenClaw executes the tool and before the result is returned to Codex. It -is separate from the public `tool_result_persist` plugin hook, which transforms -OpenClaw-owned transcript tool-result writes. +Plugins can also register harness-neutral tool-result middleware to rewrite +OpenClaw dynamic tool results after OpenClaw executes the tool and before the +result is returned to Codex. This is separate from the public +`tool_result_persist` plugin hook, which transforms OpenClaw-owned transcript +tool-result writes. The harness is off by default. New configs should keep OpenAI model refs canonical as `openai/gpt-*` and explicitly force diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 6d460f9c021..7aa849067ee 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -396,7 +396,7 @@ read without importing the plugin runtime. ```json { "contracts": { - "embeddedExtensionFactories": ["pi"], + "agentToolResultMiddleware": ["pi", "codex-app-server"], "externalAuthProviders": ["acme-ai"], "speechProviders": ["openai"], "realtimeTranscriptionProviders": ["openai"], @@ -414,20 +414,26 @@ read without importing the plugin runtime. Each list is optional: -| Field | Type | What it means | -| -------------------------------- | ---------- | ----------------------------------------------------------------- | -| `embeddedExtensionFactories` | `string[]` | Embedded runtime ids a bundled plugin may register factories for. | -| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. | -| `speechProviders` | `string[]` | Speech provider ids this plugin owns. | -| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. | -| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. | -| `memoryEmbeddingProviders` | `string[]` | Memory embedding provider ids this plugin owns. | -| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. | -| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. | -| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. | -| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. | -| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. | -| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. | +| Field | Type | What it means | +| -------------------------------- | ---------- | ---------------------------------------------------------------- | +| `embeddedExtensionFactories` | `string[]` | Deprecated embedded extension factory ids. | +| `agentToolResultMiddleware` | `string[]` | Harness ids this plugin may register tool-result middleware for. | +| `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. | +| `speechProviders` | `string[]` | Speech provider ids this plugin owns. | +| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. | +| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. | +| `memoryEmbeddingProviders` | `string[]` | Memory embedding provider ids this plugin owns. | +| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. | +| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. | +| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. | +| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. | +| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. | +| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. | + +`contracts.embeddedExtensionFactories` is retained for bundled compatibility +code that still needs direct Pi embedded-runner events. New tool-result +transforms should declare `contracts.agentToolResultMiddleware` and register +with `api.registerAgentToolResultMiddleware(...)` instead. Provider plugins that implement `resolveExternalAuthProfiles` should declare `contracts.externalAuthProviders`. Plugins without the declaration still run diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 70fc41c37c6..60eeff4e8de 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -144,14 +144,20 @@ OpenClaw requires Codex app-server `0.118.0` or newer. The Codex plugin checks the app-server initialize handshake and blocks older or unversioned servers so OpenClaw only runs against the protocol surface it has been tested with. -### Codex app-server tool-result middleware +### Tool-result middleware -Bundled plugins can also attach Codex app-server-specific `tool_result` -middleware through `api.registerCodexAppServerExtensionFactory(...)` when their -manifest declares `contracts.embeddedExtensionFactories: ["codex-app-server"]`. -This is the trusted-plugin seam for async tool-result transforms that need to -run inside the native Codex harness before the tool output is projected back -into the OpenClaw transcript. +Plugins can attach harness-neutral tool-result middleware through +`api.registerAgentToolResultMiddleware(...)` when their manifest declares the +targeted harness ids in `contracts.agentToolResultMiddleware`. This is the seam +for async tool-result transforms that must run before PI or Codex feeds tool +output back into the model. + +Legacy bundled plugins can still use +`api.registerCodexAppServerExtensionFactory(...)` for Codex app-server-only +middleware, but new result transforms should use the harness-neutral API. +The Pi-only `api.registerEmbeddedExtensionFactory(...)` hook is deprecated for +tool-result transforms; keep it only for bundled compatibility code that still +needs direct Pi embedded-runner events. ### Native Codex harness mode diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index d0db094a591..36eb486e33a 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -5,6 +5,7 @@ sidebarTitle: "Migrate to SDK" read_when: - You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning + - You use api.registerEmbeddedExtensionFactory - You are updating a plugin to the modern plugin architecture - You maintain an external OpenClaw plugin --- @@ -23,8 +24,10 @@ anything they needed from a single entry point: new plugin architecture was being built. - **`openclaw/extension-api`** — a bridge that gave plugins direct access to host-side helpers like the embedded agent runner. +- **`api.registerEmbeddedExtensionFactory(...)`** — a Pi-only bundled extension + hook that could observe embedded-runner events such as `tool_result`. -Both surfaces are now **deprecated**. They still work at runtime, but new +These surfaces are now **deprecated**. They still work at runtime, but new plugins must not use them, and existing plugins should migrate before the next major release removes them. @@ -87,6 +90,41 @@ releases. ## How to migrate + + Replace Pi-only `api.registerEmbeddedExtensionFactory(...)` tool-result + handlers with harness-neutral middleware. + + ```typescript + // Before: Pi-only compatibility hook + api.registerEmbeddedExtensionFactory((pi) => { + pi.on("tool_result", async (event) => { + return compactToolResult(event); + }); + }); + + // After: Pi and Codex app-server dynamic tools + api.registerAgentToolResultMiddleware(async (event) => { + return compactToolResult(event); + }, { + harnesses: ["pi", "codex-app-server"], + }); + ``` + + Update the plugin manifest at the same time: + + ```json + { + "contracts": { + "agentToolResultMiddleware": ["pi", "codex-app-server"] + } + } + ``` + + Keep `contracts.embeddedExtensionFactories` only for bundled compatibility + code that still needs direct Pi embedded-runner events. + + + Approval-capable channel plugins now expose native approval behavior through `approvalCapability.nativeRuntime` plus the shared runtime-context registry. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index be6ae861eda..a7421ef8f7e 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -99,7 +99,8 @@ methods: | `api.registerCli(registrar, opts?)` | CLI subcommand | | `api.registerService(service)` | Background service | | `api.registerInteractiveHandler(registration)` | Interactive handler | -| `api.registerEmbeddedExtensionFactory(factory)` | Pi embedded-runner extension factory | +| `api.registerAgentToolResultMiddleware(...)` | Harness tool-result middleware | +| `api.registerEmbeddedExtensionFactory(factory)` | Deprecated PI extension factory | | `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section | | `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus | @@ -110,15 +111,22 @@ methods: plugin-owned methods. - - Use `api.registerEmbeddedExtensionFactory(...)` when a plugin needs Pi-native - event timing during OpenClaw embedded runs — for example async `tool_result` - rewrites that must happen before the final tool-result message is emitted. + + Use `api.registerAgentToolResultMiddleware(...)` when a plugin needs to + rewrite a tool result after execution and before the harness feeds that + result back into the model. This is the harness-neutral seam for async output + reducers such as tokenjuice. -This is a bundled-plugin seam today: only bundled plugins may register one, -and they must declare `contracts.embeddedExtensionFactories: ["pi"]` in -`openclaw.plugin.json`. Keep normal OpenClaw plugin hooks for everything that -does not require that lower-level seam. +Plugins must declare `contracts.agentToolResultMiddleware` for each targeted +harness, for example `["pi", "codex-app-server"]`. Keep normal OpenClaw +plugin hooks for work that does not need pre-model tool-result timing. + + + + `api.registerEmbeddedExtensionFactory(...)` is deprecated. It remains a + compatibility seam for bundled plugins that still need direct Pi + embedded-runner events. New tool-result transforms should use + `api.registerAgentToolResultMiddleware(...)` instead. ### Gateway discovery registration diff --git a/docs/tools/tokenjuice.md b/docs/tools/tokenjuice.md index adbfbe06f31..de7a76b4052 100644 --- a/docs/tools/tokenjuice.md +++ b/docs/tools/tokenjuice.md @@ -13,8 +13,9 @@ tool results after the command has already run. It changes the returned `tool_result`, not the command itself. Tokenjuice does not rewrite shell input, rerun commands, or change exit codes. -Today this applies to Pi embedded runs, where tokenjuice hooks the embedded -`tool_result` path and trims the output that goes back into the session. +Today this applies to PI embedded runs and OpenClaw dynamic tools in the Codex +app-server harness. Tokenjuice hooks OpenClaw's tool-result middleware and +trims the output before it goes back into the active harness session. ## Enable the plugin diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 138504ab417..6c6ff49200b 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -210,7 +210,54 @@ describe("createCodexDynamicToolBridge", () => { }); }); - it("applies codex app-server tool_result extensions from the active plugin registry", async () => { + it("applies agent tool result middleware from the active plugin registry", async () => { + const registry = createEmptyPluginRegistry(); + const handler = vi.fn( + async (event: { result: AgentToolResult; toolName: string }) => ({ + result: { + ...event.result, + content: [{ type: "text" as const, text: `${event.toolName} compacted` }], + }, + }), + ); + registry.agentToolResultMiddlewares.push({ + pluginId: "tokenjuice", + pluginName: "Tokenjuice", + rawHandler: handler, + handler, + harnesses: ["codex-app-server"], + source: "test", + }); + setActivePluginRegistry(registry); + + const bridge = createBridgeWithToolResult("exec", { + content: [{ type: "text", text: "raw output" }], + details: {}, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + namespace: null, + tool: "exec", + arguments: { command: "git status" }, + }); + + expect(result).toEqual(expectInputText("exec compacted")); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + turnId: "turn-1", + toolCallId: "call-1", + toolName: "exec", + args: { command: "git status" }, + }), + expect.objectContaining({ harness: "codex-app-server" }), + ); + }); + + it("still applies legacy codex app-server extension factories after middleware", async () => { const registry = createEmptyPluginRegistry(); const factory = async (codex: { on: ( @@ -221,7 +268,7 @@ describe("createCodexDynamicToolBridge", () => { codex.on("tool_result", async (event) => ({ result: { ...event.result, - content: [{ type: "text", text: `${event.toolName} compacted` }], + content: [{ type: "text", text: "legacy compacted" }], }, })); }; @@ -248,7 +295,7 @@ describe("createCodexDynamicToolBridge", () => { arguments: { command: "git status" }, }); - expect(result).toEqual(expectInputText("exec compacted")); + expect(result).toEqual(expectInputText("legacy compacted")); }); it("fires after_tool_call for successful codex tool executions", async () => { @@ -441,29 +488,25 @@ describe("createCodexDynamicToolBridge", () => { ]), ); const registry = createEmptyPluginRegistry(); - const factory = async (codex: { - on: ( - event: "tool_result", - handler: (event: any) => Promise<{ result: AgentToolResult }>, - ) => void; - }) => { - codex.on("tool_result", async (event) => { + const handler = vi.fn( + async (event: { args: Record; result: AgentToolResult }) => { events.push("middleware"); expect(event.args).toEqual({ command: "status" }); return { result: { ...event.result, - content: [{ type: "text", text: "compacted output" }], + content: [{ type: "text" as const, text: "compacted output" }], details: { stage: "middleware" }, }, }; - }); - }; - registry.codexAppServerExtensionFactories.push({ + }, + ); + registry.agentToolResultMiddlewares.push({ pluginId: "tokenjuice", pluginName: "Tokenjuice", - rawFactory: factory, - factory, + rawHandler: handler, + handler, + harnesses: ["codex-app-server"], source: "test", }); setActivePluginRegistry(registry); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 940b4334b07..69c5f56ff2b 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { + createAgentToolResultMiddlewareRunner, createCodexAppServerToolResultExtensionRunner, extractToolResultMediaArtifact, filterToolResultMediaUrls, @@ -58,7 +59,13 @@ export function createCodexDynamicToolBridge(params: { toolMediaUrls: [], toolAudioAsVoice: false, }; - const extensionRunner = createCodexAppServerToolResultExtensionRunner(params.hookContext ?? {}); + const middlewareRunner = createAgentToolResultMiddlewareRunner({ + harness: "codex-app-server", + ...params.hookContext, + }); + const legacyExtensionRunner = createCodexAppServerToolResultExtensionRunner( + params.hookContext ?? {}, + ); return { specs: tools.map((tool) => ({ @@ -80,7 +87,7 @@ export function createCodexDynamicToolBridge(params: { try { const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args; const rawResult = await tool.execute(call.callId, preparedArgs, params.signal); - const result = await extensionRunner.applyToolResultExtensions({ + const middlewareResult = await middlewareRunner.applyToolResultMiddleware({ threadId: call.threadId, turnId: call.turnId, toolCallId: call.callId, @@ -88,6 +95,14 @@ export function createCodexDynamicToolBridge(params: { args, result: rawResult, }); + const result = await legacyExtensionRunner.applyToolResultExtensions({ + threadId: call.threadId, + turnId: call.turnId, + toolCallId: call.callId, + toolName: tool.name, + args, + result: middlewareResult, + }); collectToolTelemetry({ toolName: tool.name, args, diff --git a/extensions/tokenjuice/index.test.ts b/extensions/tokenjuice/index.test.ts index 79d7008bbcd..73e9e9c5c4d 100644 --- a/extensions/tokenjuice/index.test.ts +++ b/extensions/tokenjuice/index.test.ts @@ -31,8 +31,8 @@ describe("tokenjuice bundled plugin", () => { expect(manifest.enabledByDefault).toBeUndefined(); }); - it("registers the tokenjuice embedded extension factory", () => { - const registerEmbeddedExtensionFactory = vi.fn(); + it("registers tokenjuice tool result middleware for Pi and Codex app-server", () => { + const registerAgentToolResultMiddleware = vi.fn(); plugin.register( createTestPluginApi({ @@ -42,11 +42,14 @@ describe("tokenjuice bundled plugin", () => { config: {}, pluginConfig: {}, runtime: {} as never, - registerEmbeddedExtensionFactory, + registerAgentToolResultMiddleware, }), ); expect(createTokenjuiceOpenClawEmbeddedExtension).toHaveBeenCalledTimes(1); - expect(registerEmbeddedExtensionFactory).toHaveBeenCalledWith(tokenjuiceFactory); + expect(tokenjuiceFactory).toHaveBeenCalledTimes(1); + expect(registerAgentToolResultMiddleware).toHaveBeenCalledWith(expect.any(Function), { + harnesses: ["pi", "codex-app-server"], + }); }); }); diff --git a/extensions/tokenjuice/index.ts b/extensions/tokenjuice/index.ts index cf7e0ae5cfa..fa52c58b9ac 100644 --- a/extensions/tokenjuice/index.ts +++ b/extensions/tokenjuice/index.ts @@ -1,11 +1,13 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createTokenjuiceOpenClawEmbeddedExtension } from "./runtime-api.js"; +import { createTokenjuiceAgentToolResultMiddleware } from "./tool-result-middleware.js"; export default definePluginEntry({ id: "tokenjuice", name: "tokenjuice", description: "Compacts exec and bash tool results with tokenjuice reducers.", register(api) { - api.registerEmbeddedExtensionFactory(createTokenjuiceOpenClawEmbeddedExtension()); + api.registerAgentToolResultMiddleware(createTokenjuiceAgentToolResultMiddleware(), { + harnesses: ["pi", "codex-app-server"], + }); }, }); diff --git a/extensions/tokenjuice/manifest.test.ts b/extensions/tokenjuice/manifest.test.ts index 2e3069bba18..9f6709643ff 100644 --- a/extensions/tokenjuice/manifest.test.ts +++ b/extensions/tokenjuice/manifest.test.ts @@ -12,7 +12,7 @@ type TokenjuicePackageManifest = { type TokenjuicePluginManifest = { contracts?: { - embeddedExtensionFactories?: string[]; + agentToolResultMiddleware?: string[]; }; }; @@ -26,11 +26,11 @@ describe("tokenjuice package manifest", () => { expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); }); - it("declares Pi embedded extension factory ownership in the manifest contract", () => { + it("declares harness-neutral tool result middleware ownership in the manifest contract", () => { const manifest = JSON.parse( fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), ) as TokenjuicePluginManifest; - expect(manifest.contracts?.embeddedExtensionFactories).toEqual(["pi"]); + expect(manifest.contracts?.agentToolResultMiddleware).toEqual(["pi", "codex-app-server"]); }); }); diff --git a/extensions/tokenjuice/openclaw.plugin.json b/extensions/tokenjuice/openclaw.plugin.json index de95bb592a5..374805aedd5 100644 --- a/extensions/tokenjuice/openclaw.plugin.json +++ b/extensions/tokenjuice/openclaw.plugin.json @@ -3,7 +3,7 @@ "name": "tokenjuice", "description": "Compacts exec and bash tool results with tokenjuice reducers.", "contracts": { - "embeddedExtensionFactories": ["pi"] + "agentToolResultMiddleware": ["pi", "codex-app-server"] }, "configSchema": { "type": "object", diff --git a/extensions/tokenjuice/tokenjuice-openclaw.ts b/extensions/tokenjuice/tokenjuice-openclaw.ts index f3aa963e640..89e5d9b8828 100644 --- a/extensions/tokenjuice/tokenjuice-openclaw.ts +++ b/extensions/tokenjuice/tokenjuice-openclaw.ts @@ -1,5 +1,7 @@ declare module "tokenjuice/openclaw" { - export function createTokenjuiceOpenClawEmbeddedExtension(): Parameters< - import("openclaw/plugin-sdk/plugin-entry").OpenClawPluginApi["registerEmbeddedExtensionFactory"] - >[0]; + type OpenClawPiRuntime = { + on(event: string, handler: (event: unknown, ctx: { cwd: string }) => unknown): void; + }; + + export function createTokenjuiceOpenClawEmbeddedExtension(): (pi: OpenClawPiRuntime) => void; } diff --git a/extensions/tokenjuice/tool-result-middleware.ts b/extensions/tokenjuice/tool-result-middleware.ts new file mode 100644 index 00000000000..9cfb0fffb23 --- /dev/null +++ b/extensions/tokenjuice/tool-result-middleware.ts @@ -0,0 +1,63 @@ +import process from "node:process"; +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareEvent, + OpenClawAgentToolResult, +} from "openclaw/plugin-sdk/agent-harness"; +import { createTokenjuiceOpenClawEmbeddedExtension } from "./runtime-api.js"; + +type TokenjuiceToolResultHandler = ( + event: { + toolName: string; + input: Record; + content: OpenClawAgentToolResult["content"]; + details: unknown; + isError?: boolean; + }, + ctx: { cwd: string }, +) => Promise | void> | Partial | void; + +function readCwd(event: AgentToolResultMiddlewareEvent): string { + if (event.cwd?.trim()) { + return event.cwd; + } + const workdir = event.args.workdir; + if (typeof workdir === "string" && workdir.trim()) { + return workdir; + } + return process.cwd(); +} + +export function createTokenjuiceAgentToolResultMiddleware(): AgentToolResultMiddleware { + const handlers: TokenjuiceToolResultHandler[] = []; + createTokenjuiceOpenClawEmbeddedExtension()({ + on(event, handler) { + if (event === "tool_result") { + handlers.push(handler as TokenjuiceToolResultHandler); + } + }, + }); + + return async (event) => { + let current = event.result; + for (const handler of handlers) { + const next = await handler( + { + toolName: event.toolName, + input: event.args, + content: current.content, + details: current.details, + isError: event.isError, + }, + { cwd: readCwd(event) }, + ); + if (next) { + current = Object.assign({}, current, { + content: next.content ?? current.content, + details: next.details ?? current.details, + }); + } + } + return current === event.result ? undefined : { result: current }; + }; +} diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts index fe6af762163..2e13cf7bc4d 100644 --- a/src/agents/codex-app-server.extensions.test.ts +++ b/src/agents/codex-app-server.extensions.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; -import { createCodexAppServerToolResultExtensionRunner } from "../plugin-sdk/agent-harness.js"; +import { + createAgentToolResultMiddlewareRunner, + createCodexAppServerToolResultExtensionRunner, +} from "../plugin-sdk/agent-harness.js"; +import { listAgentToolResultMiddlewares } from "../plugins/agent-tool-result-middleware.js"; import { listCodexAppServerExtensionFactories } from "../plugins/codex-app-server-extension-factory.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { @@ -20,6 +24,137 @@ afterEach(() => { cleanupTempPluginTestEnvironment(tempDirs, originalBundledPluginsDir); }); +describe("agent tool result middleware", () => { + it("includes plugin-registered middleware and restores it from cache", async () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "tool-result-middleware", + filename: "index.mjs", + manifest: { + contracts: { + agentToolResultMiddleware: ["codex-app-server"], + }, + }, + body: `export default { id: "tool-result-middleware", register(api) { + api.registerAgentToolResultMiddleware(async (event) => ({ + result: { ...event.result, content: [{ type: "text", text: event.toolName + " compacted" }] } + }), { harnesses: ["codex-app-server"] }); +} };`, + }); + + const options = { + config: { + plugins: { + entries: { + "tool-result-middleware": { + enabled: true, + }, + }, + }, + }, + }; + + loadOpenClawPlugins(options); + expect(listAgentToolResultMiddlewares("codex-app-server")).toHaveLength(1); + expect(listAgentToolResultMiddlewares("pi")).toHaveLength(0); + + resetActivePluginRegistryForTest(); + expect(listAgentToolResultMiddlewares("codex-app-server")).toHaveLength(0); + + loadOpenClawPlugins(options); + const runner = createAgentToolResultMiddlewareRunner({ harness: "codex-app-server" }); + 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 compacted" }]); + }); + + it("rejects middleware when the manifest omits the harness contract", () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "tool-result-middleware", + filename: "index.mjs", + manifest: { + contracts: { + agentToolResultMiddleware: ["pi"], + }, + }, + body: `export default { id: "tool-result-middleware", register(api) { + api.registerAgentToolResultMiddleware(() => undefined, { harnesses: ["codex-app-server"] }); +} };`, + }); + + const registry = loadOpenClawPlugins({ + config: { + plugins: { + entries: { + "tool-result-middleware": { + enabled: true, + }, + }, + }, + }, + }); + + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "tool-result-middleware", + message: "plugin must declare contracts.agentToolResultMiddleware for: codex-app-server", + }), + ); + expect(listAgentToolResultMiddlewares("codex-app-server")).toHaveLength(0); + }); + + it("merges harnesses when a plugin registers the same middleware function twice", () => { + const tmp = createTempDir(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp; + + writeTempPlugin({ + dir: tmp, + id: "tool-result-middleware", + filename: "index.mjs", + manifest: { + contracts: { + agentToolResultMiddleware: ["pi", "codex-app-server"], + }, + }, + body: `const middleware = () => undefined; +export default { id: "tool-result-middleware", register(api) { + api.registerAgentToolResultMiddleware(middleware, { harnesses: ["pi"] }); + api.registerAgentToolResultMiddleware(middleware, { harnesses: ["codex-app-server"] }); +} };`, + }); + + loadOpenClawPlugins({ + config: { + plugins: { + entries: { + "tool-result-middleware": { + enabled: true, + }, + }, + }, + }, + }); + + expect(listAgentToolResultMiddlewares("pi")).toHaveLength(1); + expect(listAgentToolResultMiddlewares("codex-app-server")).toHaveLength(1); + }); +}); + describe("Codex app-server extension factories", () => { it("includes plugin-registered Codex app-server extension factories and restores them from cache", async () => { const tmp = createTempDir(); diff --git a/src/agents/harness/tool-result-middleware.ts b/src/agents/harness/tool-result-middleware.ts new file mode 100644 index 00000000000..761b317993b --- /dev/null +++ b/src/agents/harness/tool-result-middleware.ts @@ -0,0 +1,37 @@ +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareContext, + AgentToolResultMiddlewareEvent, + OpenClawAgentToolResult, +} from "../../plugins/agent-tool-result-middleware-types.js"; +import { listAgentToolResultMiddlewares } from "../../plugins/agent-tool-result-middleware.js"; + +const log = createSubsystemLogger("agents/harness"); + +export function createAgentToolResultMiddlewareRunner( + ctx: AgentToolResultMiddlewareContext, + handlers: AgentToolResultMiddleware[] = listAgentToolResultMiddlewares(ctx.harness), +) { + return { + async applyToolResultMiddleware( + event: AgentToolResultMiddlewareEvent, + ): Promise { + let current = event.result; + for (const handler of handlers) { + try { + const next = await handler({ ...event, result: current }, ctx); + if (next?.result) { + current = next.result; + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log.warn( + `[${ctx.harness}] tool result middleware failed for ${event.toolName}: ${detail}`, + ); + } + } + return current; + }, + }; +} diff --git a/src/agents/pi-embedded-runner.extensions.test.ts b/src/agents/pi-embedded-runner.extensions.test.ts index 710e3cbd3b1..78397379027 100644 --- a/src/agents/pi-embedded-runner.extensions.test.ts +++ b/src/agents/pi-embedded-runner.extensions.test.ts @@ -63,7 +63,7 @@ describe("buildEmbeddedExtensionFactories", () => { modelId: "gpt-5.4", model: undefined, }); - expect(firstFactories).toHaveLength(1); + expect(firstFactories).toHaveLength(2); expect(listEmbeddedExtensionFactories()).toHaveLength(1); resetActivePluginRegistryForTest(); @@ -78,10 +78,10 @@ describe("buildEmbeddedExtensionFactories", () => { modelId: "gpt-5.4", model: undefined, }); - expect(cachedFactories).toHaveLength(1); + expect(cachedFactories).toHaveLength(2); const handlers = new Map(); - await cachedFactories[0]?.({ + await cachedFactories[1]?.({ on(event: string, handler: Function) { handlers.set(event, handler); }, @@ -134,7 +134,7 @@ describe("buildEmbeddedExtensionFactories", () => { modelId: "gpt-5.4", model: undefined, }), - ).toHaveLength(0); + ).toHaveLength(1); }); it("rejects bundled plugins that omit the Pi embedded extension manifest contract", () => { @@ -254,10 +254,10 @@ describe("buildEmbeddedExtensionFactories", () => { modelId: "gpt-5.4", model: undefined, }); - expect(factories).toHaveLength(1); + expect(factories).toHaveLength(2); await expect( - factories[0]?.({ + factories[1]?.({ on() {}, } as never), ).resolves.toBeUndefined(); diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index f17533c9f6f..a9b43b758bd 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -1,9 +1,11 @@ +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 { listEmbeddedExtensionFactories } from "../../plugins/embedded-extension-factory.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"; +import { createAgentToolResultMiddlewareRunner } from "../harness/tool-result-middleware.js"; import { setCompactionSafeguardRuntime } from "../pi-hooks/compaction-safeguard-runtime.js"; import compactionSafeguardExtension from "../pi-hooks/compaction-safeguard.js"; import contextPruningExtension from "../pi-hooks/context-pruning.js"; @@ -14,6 +16,57 @@ import { ensurePiCompactionReserveTokens } from "../pi-settings.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js"; +type PiToolResultEvent = { + threadId?: string; + turnId?: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + content?: AgentToolResult["content"]; + details?: unknown; + isError?: boolean; +}; + +function recordFromUnknown(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function buildAgentToolResultMiddlewareFactory(): ExtensionFactory { + const runner = createAgentToolResultMiddlewareRunner({ harness: "pi" }); + return (pi) => { + pi.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => { + const event = recordFromUnknown(rawEvent) as PiToolResultEvent; + if (!event.toolName) { + return undefined; + } + const content = Array.isArray(event.content) ? event.content : []; + const current = { + content, + details: event.details, + } satisfies AgentToolResult; + const result = await runner.applyToolResultMiddleware({ + threadId: event.threadId, + turnId: event.turnId, + toolCallId: event.toolCallId ?? event.toolName, + toolName: event.toolName, + args: recordFromUnknown(event.input), + cwd: ctx.cwd, + isError: event.isError, + result: current, + }); + if (result === current) { + return undefined; + } + return { + content: result.content, + details: result.details, + }; + }); + }; +} + function resolveContextWindowTokens(params: { cfg: OpenClawConfig | undefined; provider: string; @@ -115,6 +168,7 @@ export function buildEmbeddedExtensionFactories(params: { if (pruningFactory) { factories.push(pruningFactory); } + factories.push(buildAgentToolResultMiddlewareFactory()); factories.push(...listEmbeddedExtensionFactories()); return factories; } diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index a23500ce978..7a32eabccc1 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -90,6 +90,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ memoryEmbeddingProviders: [], embeddedExtensionFactories: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index 696dc3c7bd2..70462336d16 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -24,6 +24,7 @@ function createStubPluginRegistry(): PluginRegistry { webSearchProviders: [], embeddedExtensionFactories: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index a463c83ddec..323b6dc0645 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -30,6 +30,15 @@ export type { MessagingToolSend } from "../agents/pi-embedded-messaging.types.js export type { AgentApprovalEventData } from "../infra/agent-events.js"; export type { ExecApprovalDecision } from "../infra/exec-approvals.js"; export type { NormalizedUsage } from "../agents/usage.js"; +export type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareContext, + AgentToolResultMiddlewareEvent, + AgentToolResultMiddlewareHarness, + AgentToolResultMiddlewareOptions, + AgentToolResultMiddlewareResult, + OpenClawAgentToolResult, +} from "../plugins/agent-tool-result-middleware-types.js"; export type { CodexAppServerExtensionContext, CodexAppServerExtensionFactory, @@ -84,6 +93,7 @@ export { runAgentHarnessBeforeCompactionHook, } from "../agents/harness/prompt-compaction-hook-helpers.js"; export { createCodexAppServerToolResultExtensionRunner } from "../agents/harness/codex-app-server-extensions.js"; +export { createAgentToolResultMiddlewareRunner } from "../agents/harness/tool-result-middleware.js"; export { assembleHarnessContextEngine, bootstrapHarnessContextEngine, diff --git a/src/plugins/agent-tool-result-middleware-types.ts b/src/plugins/agent-tool-result-middleware-types.ts new file mode 100644 index 00000000000..c99586a50c2 --- /dev/null +++ b/src/plugins/agent-tool-result-middleware-types.ts @@ -0,0 +1,37 @@ +import type { AgentToolResult as PiAgentToolResult } from "@mariozechner/pi-agent-core"; + +export type OpenClawAgentToolResult = PiAgentToolResult; + +export type AgentToolResultMiddlewareHarness = "pi" | "codex-app-server"; + +export type AgentToolResultMiddlewareEvent = { + threadId?: string; + turnId?: string; + toolCallId: string; + toolName: string; + args: Record; + cwd?: string; + isError?: boolean; + result: OpenClawAgentToolResult; +}; + +export type AgentToolResultMiddlewareContext = { + harness: AgentToolResultMiddlewareHarness; + agentId?: string; + sessionId?: string; + sessionKey?: string; + runId?: string; +}; + +export type AgentToolResultMiddlewareResult = { + result: OpenClawAgentToolResult; +}; + +export type AgentToolResultMiddleware = ( + event: AgentToolResultMiddlewareEvent, + ctx: AgentToolResultMiddlewareContext, +) => Promise | AgentToolResultMiddlewareResult | void; + +export type AgentToolResultMiddlewareOptions = { + harnesses?: AgentToolResultMiddlewareHarness[]; +}; diff --git a/src/plugins/agent-tool-result-middleware.ts b/src/plugins/agent-tool-result-middleware.ts new file mode 100644 index 00000000000..33aa33ee228 --- /dev/null +++ b/src/plugins/agent-tool-result-middleware.ts @@ -0,0 +1,44 @@ +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareHarness, + AgentToolResultMiddlewareOptions, +} from "./agent-tool-result-middleware-types.js"; +import { getActivePluginRegistry } from "./runtime.js"; + +export const AGENT_TOOL_RESULT_MIDDLEWARE_HARNESSES = [ + "pi", + "codex-app-server", +] as const satisfies AgentToolResultMiddlewareHarness[]; + +const AGENT_TOOL_RESULT_MIDDLEWARE_HARNESS_SET = new Set( + AGENT_TOOL_RESULT_MIDDLEWARE_HARNESSES, +); + +export function normalizeAgentToolResultMiddlewareHarnesses( + options?: AgentToolResultMiddlewareOptions, +): AgentToolResultMiddlewareHarness[] { + const requested = options?.harnesses; + if (!requested || requested.length === 0) { + return [...AGENT_TOOL_RESULT_MIDDLEWARE_HARNESSES]; + } + const normalized: AgentToolResultMiddlewareHarness[] = []; + for (const harness of requested) { + if (!AGENT_TOOL_RESULT_MIDDLEWARE_HARNESS_SET.has(harness)) { + continue; + } + if (!normalized.includes(harness)) { + normalized.push(harness); + } + } + return normalized; +} + +export function listAgentToolResultMiddlewares( + harness: AgentToolResultMiddlewareHarness, +): AgentToolResultMiddleware[] { + return ( + getActivePluginRegistry() + ?.agentToolResultMiddlewares?.filter((entry) => entry.harnesses.includes(harness)) + .map((entry) => entry.handler) ?? [] + ); +} diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 323571ad4b8..1bba2e148c8 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -51,6 +51,7 @@ export type BuildPluginApiParams = { | "registerAgentHarness" | "registerEmbeddedExtensionFactory" | "registerCodexAppServerExtensionFactory" + | "registerAgentToolResultMiddleware" | "registerDetachedTaskRuntime" | "registerMemoryCapability" | "registerMemoryPromptSection" @@ -108,6 +109,8 @@ const noopRegisterEmbeddedExtensionFactory: OpenClawPluginApi["registerEmbeddedE () => {}; const noopRegisterCodexAppServerExtensionFactory: OpenClawPluginApi["registerCodexAppServerExtensionFactory"] = () => {}; +const noopRegisterAgentToolResultMiddleware: OpenClawPluginApi["registerAgentToolResultMiddleware"] = + () => {}; const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {}; const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {}; const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {}; @@ -181,6 +184,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi handlers.registerEmbeddedExtensionFactory ?? noopRegisterEmbeddedExtensionFactory, registerCodexAppServerExtensionFactory: handlers.registerCodexAppServerExtensionFactory ?? noopRegisterCodexAppServerExtensionFactory, + registerAgentToolResultMiddleware: + handlers.registerAgentToolResultMiddleware ?? noopRegisterAgentToolResultMiddleware, registerDetachedTaskRuntime: handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime, registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 44e4ce7f721..54fe1881b73 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -1,5 +1,6 @@ import type { ExtensionFactory } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { AgentToolResultMiddleware } from "./agent-tool-result-middleware-types.js"; import { buildPluginApi } from "./api-builder.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; @@ -39,6 +40,7 @@ export type CapturedPluginRegistration = { textTransforms: PluginTextTransformRegistration[]; embeddedExtensionFactories: ExtensionFactory[]; codexAppServerExtensionFactories: CodexAppServerExtensionFactory[]; + agentToolResultMiddlewares: AgentToolResultMiddleware[]; speechProviders: SpeechProviderPlugin[]; realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[]; realtimeVoiceProviders: RealtimeVoiceProviderPlugin[]; @@ -63,6 +65,7 @@ export function createCapturedPluginRegistration(params?: { const textTransforms: PluginTextTransformRegistration[] = []; const embeddedExtensionFactories: ExtensionFactory[] = []; const codexAppServerExtensionFactories: CodexAppServerExtensionFactory[] = []; + const agentToolResultMiddlewares: AgentToolResultMiddleware[] = []; const speechProviders: SpeechProviderPlugin[] = []; const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = []; const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = []; @@ -89,6 +92,7 @@ export function createCapturedPluginRegistration(params?: { textTransforms, embeddedExtensionFactories, codexAppServerExtensionFactories, + agentToolResultMiddlewares, speechProviders, realtimeTranscriptionProviders, realtimeVoiceProviders, @@ -145,6 +149,9 @@ export function createCapturedPluginRegistration(params?: { registerCodexAppServerExtensionFactory(factory: CodexAppServerExtensionFactory) { codexAppServerExtensionFactories.push(factory); }, + registerAgentToolResultMiddleware(handler: AgentToolResultMiddleware) { + agentToolResultMiddlewares.push(handler); + }, registerCliBackend(backend: CliBackendPlugin) { cliBackends.push(backend); }, diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index fe38eeee902..91e817f88d8 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -40,12 +40,24 @@ export function createMockPluginRegistry( imageGenerationProviders: [], videoGenerationProviders: [], musicGenerationProviders: [], + webFetchProviders: [], webSearchProviders: [], + embeddedExtensionFactories: [], + codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], + memoryEmbeddingProviders: [], + agentHarnesses: [], httpRoutes: [], gatewayHandlers: {}, + gatewayMethodScopes: {}, cliRegistrars: [], + textTransforms: [], + reloads: [], + nodeHostCommands: [], + securityAuditCollectors: [], services: [], gatewayDiscoveryServices: [], + conversationBindingResolvedHandlers: [], commands: [], diagnostics: [], } as unknown as PluginRegistry; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a7c13ece67f..6c0ebf49ec1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -280,6 +280,7 @@ type PluginRegistrySnapshot = { webSearchProviders: PluginRegistry["webSearchProviders"]; embeddedExtensionFactories: PluginRegistry["embeddedExtensionFactories"]; codexAppServerExtensionFactories: PluginRegistry["codexAppServerExtensionFactories"]; + agentToolResultMiddlewares: PluginRegistry["agentToolResultMiddlewares"]; memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"]; agentHarnesses: PluginRegistry["agentHarnesses"]; httpRoutes: PluginRegistry["httpRoutes"]; @@ -318,6 +319,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho webSearchProviders: [...registry.webSearchProviders], embeddedExtensionFactories: [...registry.embeddedExtensionFactories], codexAppServerExtensionFactories: [...registry.codexAppServerExtensionFactories], + agentToolResultMiddlewares: [...registry.agentToolResultMiddlewares], memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders], agentHarnesses: [...registry.agentHarnesses], httpRoutes: [...registry.httpRoutes], @@ -355,6 +357,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr registry.webSearchProviders = snapshot.arrays.webSearchProviders; registry.embeddedExtensionFactories = snapshot.arrays.embeddedExtensionFactories; registry.codexAppServerExtensionFactories = snapshot.arrays.codexAppServerExtensionFactories; + registry.agentToolResultMiddlewares = snapshot.arrays.agentToolResultMiddlewares; registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders; registry.agentHarnesses = snapshot.arrays.agentHarnesses; registry.httpRoutes = snapshot.arrays.httpRoutes; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index b5aa4e430cd..b4e57a5b781 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -233,6 +233,7 @@ export type PluginManifest = { export type PluginManifestContracts = { embeddedExtensionFactories?: string[]; + agentToolResultMiddleware?: string[]; /** * Provider ids whose external auth profile hook can contribute runtime-only * credentials. Declaring this lets auth-store overlays load only the owning @@ -426,6 +427,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u } const embeddedExtensionFactories = normalizeTrimmedStringList(value.embeddedExtensionFactories); + const agentToolResultMiddleware = normalizeTrimmedStringList(value.agentToolResultMiddleware); const externalAuthProviders = normalizeTrimmedStringList(value.externalAuthProviders); const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders); const speechProviders = normalizeTrimmedStringList(value.speechProviders); @@ -442,6 +444,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u const tools = normalizeTrimmedStringList(value.tools); const contracts = { ...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}), + ...(agentToolResultMiddleware.length > 0 ? { agentToolResultMiddleware } : {}), ...(externalAuthProviders.length > 0 ? { externalAuthProviders } : {}), ...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}), ...(speechProviders.length > 0 ? { speechProviders } : {}), diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index d64306b4439..96145873390 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -22,6 +22,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { webSearchProviders: [], embeddedExtensionFactories: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 5e9606e8784..f912a7e3d8f 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -5,6 +5,10 @@ import type { OperatorScope } from "../gateway/operator-scopes.js"; import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; import type { HookEntry } from "../hooks/types.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareHarness, +} from "./agent-tool-result-middleware-types.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginActivationSource } from "./config-state.js"; import type { @@ -164,6 +168,15 @@ export type PluginCodexAppServerExtensionFactoryRegistration = { source: string; rootDir?: string; }; +export type PluginAgentToolResultMiddlewareRegistration = { + pluginId: string; + pluginName?: string; + rawHandler: AgentToolResultMiddleware; + handler: AgentToolResultMiddleware; + harnesses: AgentToolResultMiddlewareHarness[]; + source: string; + rootDir?: string; +}; export type PluginAgentHarnessRegistration = { pluginId: string; pluginName?: string; @@ -312,6 +325,7 @@ export type PluginRegistry = { webSearchProviders: PluginWebSearchProviderRegistration[]; embeddedExtensionFactories: PluginEmbeddedExtensionFactoryRegistration[]; codexAppServerExtensionFactories: PluginCodexAppServerExtensionFactoryRegistration[]; + agentToolResultMiddlewares: PluginAgentToolResultMiddlewareRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; agentHarnesses: PluginAgentHarnessRegistration[]; gatewayHandlers: GatewayRequestHandlers; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 71cf1876fde..ac53d0ccdb5 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -28,6 +28,8 @@ import { registerDetachedTaskLifecycleRuntime, } from "../tasks/detached-task-runtime-state.js"; import { resolveUserPath } from "../utils.js"; +import type { AgentToolResultMiddleware } from "./agent-tool-result-middleware-types.js"; +import { normalizeAgentToolResultMiddlewareHarnesses } from "./agent-tool-result-middleware.js"; import { buildPluginApi } from "./api-builder.js"; import { normalizeRegisteredChannelPlugin } from "./channel-validation.js"; import { CODEX_APP_SERVER_EXTENSION_RUNTIME_ID } from "./codex-app-server-extension-factory.js"; @@ -329,6 +331,70 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerAgentToolResultMiddleware = ( + record: PluginRecord, + handler: Parameters[0], + options: Parameters[1], + ) => { + if (typeof (handler as unknown) !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "agent tool result middleware must be a function", + }); + return; + } + const harnesses = normalizeAgentToolResultMiddlewareHarnesses(options); + if (harnesses.length === 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "agent tool result middleware must target at least one supported harness", + }); + return; + } + const declared = record.contracts?.agentToolResultMiddleware ?? []; + const missing = harnesses.filter((harness) => !declared.includes(harness)); + if (missing.length > 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `plugin must declare contracts.agentToolResultMiddleware for: ${missing.join(", ")}`, + }); + return; + } + const existing = registry.agentToolResultMiddlewares.find( + (entry) => entry.pluginId === record.id && entry.rawHandler === handler, + ); + if (existing) { + existing.harnesses = [...new Set([...existing.harnesses, ...harnesses])]; + return; + } + const safeHandler: AgentToolResultMiddleware = async (event, ctx) => { + try { + return await handler(event, ctx); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + registryParams.logger.warn( + `[plugins] agent tool result middleware failed for ${record.id}: ${detail}`, + ); + return undefined; + } + }; + registry.agentToolResultMiddlewares.push({ + pluginId: record.id, + pluginName: record.name, + rawHandler: handler, + handler: safeHandler, + harnesses, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerTool = ( record: PluginRecord, tool: AnyAgentTool | OpenClawPluginToolFactory, @@ -1466,6 +1532,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerCodexAppServerExtensionFactory: (factory) => { registerCodexAppServerExtensionFactory(record, factory); }, + registerAgentToolResultMiddleware: (handler, options) => { + registerAgentToolResultMiddleware(record, handler, options); + }, registerMemoryCapability: (capability) => { if (!hasKind(record.kind, "memory")) { pushDiagnostic({ diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 8db63c3ad0d..a00068d5cbd 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -131,6 +131,7 @@ export function createPluginLoadResult( webSearchProviders: [], embeddedExtensionFactories: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f3ac6a0502f..da3875783bf 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -69,6 +69,10 @@ import type { } from "../tts/provider-types.js"; import type { VideoGenerationProvider } from "../video-generation/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareOptions, +} from "./agent-tool-result-middleware-types.js"; import type { CliBackendAuthEpochMode, CliBackendNormalizeConfigContext, @@ -142,6 +146,15 @@ export type { } from "./tool-types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; export type { AgentHarness } from "../agents/harness/types.js"; +export type { + AgentToolResultMiddleware, + AgentToolResultMiddlewareContext, + AgentToolResultMiddlewareEvent, + AgentToolResultMiddlewareHarness, + AgentToolResultMiddlewareOptions, + AgentToolResultMiddlewareResult, + OpenClawAgentToolResult, +} from "./agent-tool-result-middleware-types.js"; export type { PluginConversationBinding, PluginConversationBindingRequestParams, @@ -2119,10 +2132,21 @@ export type OpenClawPluginApi = { ) => void; /** Register an agent harness implementation. */ registerAgentHarness: (harness: AgentHarness) => void; - /** Register a Pi embedded extension factory for OpenClaw embedded runs. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"pi"`. */ + /** + * Register a Pi embedded extension factory for OpenClaw embedded runs. + * + * @deprecated This is a bundled compatibility seam. New tool-result transforms + * should use `registerAgentToolResultMiddleware(...)` and declare + * `contracts.agentToolResultMiddleware` for the targeted harnesses. + */ registerEmbeddedExtensionFactory: (factory: ExtensionFactory) => void; /** Register a Codex app-server extension factory for Codex harness tool-result middleware. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"codex-app-server"`. */ registerCodexAppServerExtensionFactory: (factory: CodexAppServerExtensionFactory) => void; + /** Register harness-neutral tool-result middleware. Declare `contracts.agentToolResultMiddleware` for every targeted harness. */ + registerAgentToolResultMiddleware: ( + handler: AgentToolResultMiddleware, + options?: AgentToolResultMiddlewareOptions, + ) => void; /** Register the active detached task runtime for this plugin (exclusive slot). */ registerDetachedTaskRuntime: ( runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 49a58abacb9..ea73178109a 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -37,6 +37,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl webSearchProviders: [], embeddedExtensionFactories: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index 34df5d4bc38..7c2ec4890cc 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -44,6 +44,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerAgentHarness() {}, registerEmbeddedExtensionFactory() {}, registerCodexAppServerExtensionFactory() {}, + registerAgentToolResultMiddleware() {}, registerDetachedTaskRuntime() {}, registerMemoryCapability() {}, registerMemoryPromptSection() {},