feat(plugins): add harness tool result middleware (#71021)

This commit is contained in:
Vincent Koc
2026-04-24 12:39:13 -07:00
committed by GitHub
parent e471d40942
commit 47f6a98909
38 changed files with 738 additions and 86 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.*`,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
<Steps>
<Step title="Migrate Pi tool-result extensions to middleware">
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.
</Step>
<Step title="Migrate approval-native handlers to capability facts">
Approval-capable channel plugins now expose native approval behavior through
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.

View File

@@ -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.
</Note>
<Accordion title="When to use registerEmbeddedExtensionFactory">
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.
<Accordion title="When to use tool-result middleware">
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.
</Accordion>
<Accordion title="Legacy Pi extension factories">
`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.
</Accordion>
### Gateway discovery registration

View File

@@ -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

View File

@@ -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<unknown>; 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<unknown> }>,
) => void;
}) => {
codex.on("tool_result", async (event) => {
const handler = vi.fn(
async (event: { args: Record<string, unknown>; result: AgentToolResult<unknown> }) => {
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);

View File

@@ -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,

View File

@@ -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"],
});
});
});

View File

@@ -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"],
});
},
});

View File

@@ -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"]);
});
});

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
content: OpenClawAgentToolResult["content"];
details: unknown;
isError?: boolean;
},
ctx: { cwd: string },
) => Promise<Partial<OpenClawAgentToolResult> | void> | Partial<OpenClawAgentToolResult> | 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 };
};
}

View File

@@ -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();

View File

@@ -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<OpenClawAgentToolResult> {
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;
},
};
}

View File

@@ -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<string, Function>();
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();

View File

@@ -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<unknown>["content"];
details?: unknown;
isError?: boolean;
};
function recordFromUnknown(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<unknown>;
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;
}

View File

@@ -90,6 +90,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
memoryEmbeddingProviders: [],
embeddedExtensionFactories: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewares: [],
textTransforms: [],
agentHarnesses: [],
gatewayHandlers: {},

View File

@@ -24,6 +24,7 @@ function createStubPluginRegistry(): PluginRegistry {
webSearchProviders: [],
embeddedExtensionFactories: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewares: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -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,

View File

@@ -0,0 +1,37 @@
import type { AgentToolResult as PiAgentToolResult } from "@mariozechner/pi-agent-core";
export type OpenClawAgentToolResult<TResult = unknown> = PiAgentToolResult<TResult>;
export type AgentToolResultMiddlewareHarness = "pi" | "codex-app-server";
export type AgentToolResultMiddlewareEvent = {
threadId?: string;
turnId?: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
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> | AgentToolResultMiddlewareResult | void;
export type AgentToolResultMiddlewareOptions = {
harnesses?: AgentToolResultMiddlewareHarness[];
};

View File

@@ -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<string>(
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) ?? []
);
}

View File

@@ -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,

View File

@@ -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);
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 } : {}),

View File

@@ -22,6 +22,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
webSearchProviders: [],
embeddedExtensionFactories: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewares: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},

View File

@@ -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;

View File

@@ -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<OpenClawPluginApi["registerAgentToolResultMiddleware"]>[0],
options: Parameters<OpenClawPluginApi["registerAgentToolResultMiddleware"]>[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({

View File

@@ -131,6 +131,7 @@ export function createPluginLoadResult(
webSearchProviders: [],
embeddedExtensionFactories: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewares: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -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,

View File

@@ -37,6 +37,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
webSearchProviders: [],
embeddedExtensionFactories: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewares: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -44,6 +44,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerAgentHarness() {},
registerEmbeddedExtensionFactory() {},
registerCodexAppServerExtensionFactory() {},
registerAgentToolResultMiddleware() {},
registerDetachedTaskRuntime() {},
registerMemoryCapability() {},
registerMemoryPromptSection() {},