feat(codex): add tool hook parity (#70307)

* feat(codex): add tool hook parity

* fix(codex): stabilize tool hook parity

* fix(codex): tighten transcript hook typing

* fix(codex): preserve mirrored transcript idempotency

* fix(codex): normalize tool hook context
This commit is contained in:
Vincent Koc
2026-04-22 16:18:10 -07:00
committed by GitHub
parent da9700903c
commit a5128777ee
26 changed files with 877 additions and 65 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK/Pi embedded runs: add a bundled-plugin embedded extension factory seam so native plugins can extend Pi embedded runs with async runtime hooks such as `tool_result` handling instead of falling back to the older synchronous persistence path. (#69946) Thanks @vincentkoc.
- Tokenjuice: add bundled native OpenClaw support for tokenjuice as an opt-in plugin that compacts noisy `exec` and `bash` tool results in Pi embedded runs. (#69946) Thanks @vincentkoc.
- Codex harness/hooks: route native Codex app-server turns through `before_prompt_build` and emit `before_compaction` / `after_compaction` for native compaction items so prompt and compaction hooks stop drifting from Pi. Thanks @vincentkoc.
- Codex harness/plugins: add a bundled-plugin Codex app-server extension seam for async `tool_result` middleware, fire `after_tool_call` for Codex tool runs, and route mirrored Codex transcript writes through `before_message_write` so tool integrations stop diverging from Pi. Thanks @vincentkoc.
- Providers/Tencent: add the bundled Tencent Cloud provider plugin with TokenHub and Token Plan onboarding, docs, `hy3-preview` model catalog entries, and tiered Hy3 pricing metadata. (#68460) Thanks @JuniperSling.
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
- CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.

View File

@@ -1,2 +1,2 @@
23c12038821233958a3659371293384f5f69208353433c70196b2f27798a3316 plugin-sdk-api-baseline.json
40ca99eaf0bf6f1b52bb7c2208a105fbba3215d59c518e2edd93e22f52841b27 plugin-sdk-api-baseline.jsonl
2b7093a57992029cc70126d33544e02eed6c3076a3a6b4ffa6aef7664da0f33d plugin-sdk-api-baseline.json
ea6a2f2326565517b6c42a4d334f615163fb434dbad5e0b8d134c92767714256 plugin-sdk-api-baseline.jsonl

View File

@@ -134,6 +134,15 @@ 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
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.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -1,6 +1,13 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../../../../src/plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../../../../src/plugins/hooks.test-helpers.js";
import { createEmptyPluginRegistry } from "../../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../../src/plugins/runtime.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import type { JsonValue } from "./protocol.js";
@@ -58,6 +65,11 @@ async function handleMessageToolCall(
});
}
afterEach(() => {
resetGlobalHookRunner();
setActivePluginRegistry(createEmptyPluginRegistry());
});
describe("createCodexDynamicToolBridge", () => {
it.each([
{ toolName: "tts", mediaUrl: "/tmp/reply.opus", audioAsVoice: true },
@@ -152,4 +164,82 @@ describe("createCodexDynamicToolBridge", () => {
messagingToolSentTargets: [],
});
});
it("applies codex app-server tool_result extensions from the active plugin registry", async () => {
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) => ({
result: {
...event.result,
content: [{ type: "text", text: `${event.toolName} compacted` }],
},
}));
};
registry.codexAppServerExtensionFactories.push({
pluginId: "tokenjuice",
pluginName: "Tokenjuice",
rawFactory: factory,
factory,
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",
tool: "exec",
arguments: { command: "git status" },
});
expect(result).toEqual(expectInputText("exec compacted"));
});
it("fires after_tool_call for successful codex tool executions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const bridge = createBridgeWithToolResult("exec", {
content: [{ type: "text", text: "done" }],
details: {},
});
await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
tool: "exec",
arguments: { command: "pwd" },
});
await vi.waitFor(() => {
expect(afterToolCall).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "exec",
toolCallId: "call-1",
params: { command: "pwd" },
result: expect.objectContaining({
content: [{ type: "text", text: "done" }],
details: {},
}),
}),
expect.objectContaining({
toolName: "exec",
toolCallId: "call-1",
}),
);
});
});
});

View File

@@ -1,10 +1,12 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import {
createCodexAppServerToolResultExtensionRunner,
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
isMessagingTool,
isMessagingToolSendAction,
runAgentHarnessAfterToolCallHook,
type AnyAgentTool,
type MessagingToolSend,
} from "openclaw/plugin-sdk/agent-harness";
@@ -33,6 +35,12 @@ export type CodexDynamicToolBridge = {
export function createCodexDynamicToolBridge(params: {
tools: AnyAgentTool[];
signal: AbortSignal;
hookContext?: {
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
};
}): CodexDynamicToolBridge {
const toolMap = new Map(params.tools.map((tool) => [tool.name, tool]));
const telemetry: CodexDynamicToolBridge["telemetry"] = {
@@ -43,6 +51,7 @@ export function createCodexDynamicToolBridge(params: {
toolMediaUrls: [],
toolAudioAsVoice: false,
};
const extensionRunner = createCodexAppServerToolResultExtensionRunner(params.hookContext ?? {});
return {
specs: params.tools.map((tool) => ({
@@ -60,9 +69,18 @@ export function createCodexDynamicToolBridge(params: {
};
}
const args = jsonObjectToRecord(call.arguments);
const startedAt = Date.now();
try {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const result = await tool.execute(call.callId, preparedArgs, params.signal);
const rawResult = await tool.execute(call.callId, preparedArgs, params.signal);
const result = await extensionRunner.applyToolResultExtensions({
threadId: call.threadId,
turnId: call.turnId,
toolCallId: call.callId,
toolName: tool.name,
args,
result: rawResult,
});
collectToolTelemetry({
toolName: tool.name,
args,
@@ -70,6 +88,17 @@ export function createCodexDynamicToolBridge(params: {
telemetry,
isError: false,
});
void runAgentHarnessAfterToolCallHook({
toolName: tool.name,
toolCallId: call.callId,
runId: params.hookContext?.runId,
agentId: params.hookContext?.agentId,
sessionId: params.hookContext?.sessionId,
sessionKey: params.hookContext?.sessionKey,
startArgs: args,
result,
startedAt,
});
return {
contentItems: result.content.flatMap(convertToolContent),
success: true,
@@ -82,6 +111,17 @@ export function createCodexDynamicToolBridge(params: {
telemetry,
isError: true,
});
void runAgentHarnessAfterToolCallHook({
toolName: tool.name,
toolCallId: call.callId,
runId: params.hookContext?.runId,
agentId: params.hookContext?.agentId,
sessionId: params.hookContext?.sessionId,
sessionKey: params.hookContext?.sessionKey,
startArgs: args,
error: error instanceof Error ? error.message : String(error),
startedAt,
});
return {
contentItems: [
{

View File

@@ -100,6 +100,12 @@ export async function runCodexAppServerAttempt(
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: runAbortController.signal,
hookContext: {
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
},
});
const historyMessages = readMirroredSessionHistoryMessages(params.sessionFile);
const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({
@@ -279,7 +285,9 @@ export async function runCodexAppServerAttempt(
const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected });
await mirrorTranscriptBestEffort({
params,
agentId: sessionAgentId,
result,
sessionKey: sandboxSessionKey,
threadId: thread.threadId,
turnId: activeTurnId,
});
@@ -514,14 +522,17 @@ function readMirroredSessionHistoryMessages(sessionFile: string): unknown[] {
async function mirrorTranscriptBestEffort(params: {
params: EmbeddedRunAttemptParams;
agentId?: string;
result: EmbeddedRunAttemptResult;
sessionKey?: string;
threadId: string;
turnId: string;
}): Promise<void> {
try {
await mirrorCodexAppServerTranscript({
sessionFile: params.params.sessionFile,
sessionKey: params.params.sessionKey,
agentId: params.agentId,
sessionKey: params.sessionKey,
messages: params.result.messagesSnapshot,
idempotencyScope: `codex-app-server:${params.threadId}:${params.turnId}`,
});

View File

@@ -1,92 +1,186 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
castAgentMessage,
makeAgentAssistantMessage,
makeAgentUserMessage,
} from "../../../../src/agents/test-helpers/agent-message-fixtures.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../../../../src/plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../../../../src/plugins/hooks.test-helpers.js";
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
let tempDir: string;
const tempDirs: string[] = [];
function assistantMessage(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.4-codex",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
afterEach(async () => {
resetGlobalHookRunner();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
async function createTempSessionFile() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-transcript-"));
tempDirs.push(dir);
return path.join(dir, "session.jsonl");
}
describe("mirrorCodexAppServerTranscript", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-transcript-"));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("mirrors user and assistant messages into the PI transcript", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
it("mirrors user and assistant messages into the Pi transcript", async () => {
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "agent:main:session-1",
sessionKey: "session-1",
messages: [
{ role: "user", content: "hello", timestamp: 1 },
assistantMessage("Codex plan:\ninspect", 2),
assistantMessage("hi", 3),
makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
}),
],
idempotencyScope: "scope-1",
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } });
expect(records[0]?.type).toBe("session");
expect(records.slice(1).map((record) => record.message?.role)).toEqual([
"user",
"assistant",
"assistant",
]);
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"role":"user"');
expect(raw).toContain('"content":[{"type":"text","text":"hello"}]');
expect(raw).toContain('"role":"assistant"');
expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]');
expect(raw).toContain('"idempotencyKey":"scope-1:user:0"');
expect(raw).toContain('"idempotencyKey":"scope-1:assistant:1"');
});
it("deduplicates app-server turn mirrors by idempotency scope", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionFile = await createTempSessionFile();
const messages = [
{ role: "user" as const, content: "hello", timestamp: 1 },
assistantMessage("hi", 2),
];
makeAgentUserMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
makeAgentAssistantMessage({
content: [{ type: "text", text: "hi there" }],
timestamp: Date.now() + 1,
}),
] as const;
await mirrorCodexAppServerTranscript({
sessionFile,
messages,
idempotencyScope: "codex-app-server:thread-1:turn-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
await mirrorCodexAppServerTranscript({
sessionFile,
messages,
idempotencyScope: "codex-app-server:thread-1:turn-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
const records = (await fs.readFile(sessionFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { message?: { role?: string; idempotencyKey?: string } });
expect(records.slice(1).map((record) => record.message?.role)).toEqual(["user", "assistant"]);
expect(records.slice(1).map((record) => record.message?.idempotencyKey)).toEqual([
"codex-app-server:thread-1:turn-1:user:0",
"codex-app-server:thread-1:turn-1:assistant:1",
]);
.filter(Boolean)
.map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } });
expect(records.slice(1)).toHaveLength(2);
});
it("runs before_message_write before appending mirrored transcript messages", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
content: [{ type: "text", text: "hello [hooked]" }],
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]');
expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"');
});
it("preserves the computed idempotency key when hooks rewrite message keys", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: (event) => ({
message: castAgentMessage({
...((event as { message: unknown }).message as Record<string, unknown>),
idempotencyKey: "hook-rewritten-key",
}),
}),
},
]),
);
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
const raw = await fs.readFile(sessionFile, "utf8");
expect(raw).toContain('"idempotencyKey":"scope-1:assistant:0"');
expect(raw).not.toContain("hook-rewritten-key");
});
it("respects before_message_write blocking decisions", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_message_write",
handler: () => ({ block: true }),
},
]),
);
const sessionFile = await createTempSessionFile();
await mirrorCodexAppServerTranscript({
sessionFile,
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should not persist" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope-1",
});
await expect(fs.readFile(sessionFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
});

View File

@@ -5,11 +5,13 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
import {
acquireSessionWriteLock,
emitSessionTranscriptUpdate,
runAgentHarnessBeforeMessageWriteHook,
} from "openclaw/plugin-sdk/agent-harness";
export async function mirrorCodexAppServerTranscript(params: {
sessionFile: string;
sessionKey?: string;
agentId?: string;
messages: AgentMessage[];
idempotencyScope?: string;
}): Promise<void> {
@@ -39,7 +41,21 @@ export async function mirrorCodexAppServerTranscript(params: {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as Parameters<SessionManager["appendMessage"]>[0];
sessionManager.appendMessage(transcriptMessage);
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage) as unknown as Parameters<SessionManager["appendMessage"]>[0];
sessionManager.appendMessage(messageToAppend);
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}

View File

@@ -0,0 +1,263 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createCodexAppServerToolResultExtensionRunner } from "../plugin-sdk/agent-harness.js";
import { listCodexAppServerExtensionFactories } from "../plugins/codex-app-server-extension-factory.js";
import { clearPluginLoaderCache, loadOpenClawPlugins } from "../plugins/loader.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-ext-"));
tempDirs.push(dir);
return dir;
}
function writeTempPlugin(params: {
dir: string;
id: string;
body: string;
manifest?: Record<string, unknown>;
filename?: string;
}): string {
const pluginDir = path.join(params.dir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
const file = path.join(pluginDir, params.filename ?? `${params.id}.mjs`);
fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: params.id,
...params.manifest,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return file;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
clearPluginLoaderCache();
setActivePluginRegistry(createEmptyPluginRegistry());
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
});
describe("Codex app-server extension factories", () => {
it("includes plugin-registered Codex app-server extension factories and restores them from cache", async () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "codex-ext",
filename: "index.mjs",
manifest: {
contracts: {
embeddedExtensionFactories: ["codex-app-server"],
},
},
body: `export default { id: "codex-ext", register(api) {
api.registerCodexAppServerExtensionFactory((codex) => {
codex.on("tool_result", async (event) => ({
result: { ...event.result, content: [{ type: "text", text: "compacted" }] }
}));
});
} };`,
});
const options = {
config: {
plugins: {
entries: {
"codex-ext": {
enabled: true,
},
},
},
},
};
loadOpenClawPlugins(options);
expect(listCodexAppServerExtensionFactories()).toHaveLength(1);
setActivePluginRegistry(createEmptyPluginRegistry());
expect(listCodexAppServerExtensionFactories()).toHaveLength(0);
loadOpenClawPlugins(options);
const runner = createCodexAppServerToolResultExtensionRunner({});
const result = await runner.applyToolResultExtensions({
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: "compacted" }]);
});
it("rejects Codex app-server extension factories from non-bundled plugins even when they declare the contract", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const pluginFile = writeTempPlugin({
dir: tmp,
id: "codex-ext",
manifest: {
contracts: {
embeddedExtensionFactories: ["codex-app-server"],
},
},
body: `export default { id: "codex-ext", register(api) {
api.registerCodexAppServerExtensionFactory(() => undefined);
} };`,
});
const registry = loadOpenClawPlugins({
workspaceDir: tmp,
config: {
plugins: {
load: { paths: [pluginFile] },
allow: ["codex-ext"],
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "codex-ext",
message: "only bundled plugins can register Codex app-server extension factories",
}),
);
expect(listCodexAppServerExtensionFactories()).toHaveLength(0);
});
it("rejects bundled plugins that omit the Codex app-server extension contract", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "codex-ext",
filename: "index.mjs",
body: `export default { id: "codex-ext", register(api) {
api.registerCodexAppServerExtensionFactory(() => undefined);
} };`,
});
const registry = loadOpenClawPlugins({
config: {
plugins: {
entries: {
"codex-ext": {
enabled: true,
},
},
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "codex-ext",
message:
'plugin must declare contracts.embeddedExtensionFactories: ["codex-app-server"] to register Codex app-server extension factories',
}),
);
expect(listCodexAppServerExtensionFactories()).toHaveLength(0);
});
it("rejects non-function Codex app-server extension factories from bundled plugins", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "codex-ext",
filename: "index.mjs",
manifest: {
contracts: {
embeddedExtensionFactories: ["codex-app-server"],
},
},
body: `export default { id: "codex-ext", register(api) {
api.registerCodexAppServerExtensionFactory("not-a-function");
} };`,
});
const registry = loadOpenClawPlugins({
config: {
plugins: {
entries: {
"codex-ext": {
enabled: true,
},
},
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "codex-ext",
message: "codex app-server extension factory must be a function",
}),
);
expect(listCodexAppServerExtensionFactories()).toHaveLength(0);
});
it("initializes async Codex app-server extension factories in registration order", async () => {
const steps: string[] = [];
const runner = createCodexAppServerToolResultExtensionRunner({}, [
async (codex) => {
await new Promise((resolve) => setTimeout(resolve, 10));
codex.on("tool_result", async ({ result }) => {
steps.push("first");
return {
result: {
...result,
content: [{ type: "text", text: `${result.content[0]?.type}:${steps.length}` }],
},
};
});
},
async (codex) => {
codex.on("tool_result", async ({ result }) => {
steps.push("second");
return { result };
});
},
]);
await runner.applyToolResultExtensions({
threadId: "thread-1",
turnId: "turn-1",
toolCallId: "call-1",
toolName: "exec",
args: { command: "git status" },
result: { content: [{ type: "text", text: "raw" }], details: {} },
});
expect(steps).toEqual(["first", "second"]);
});
});

View File

@@ -0,0 +1,53 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { listCodexAppServerExtensionFactories } from "../../plugins/codex-app-server-extension-factory.js";
import type {
CodexAppServerExtensionContext,
CodexAppServerExtensionFactory,
CodexAppServerExtensionRuntime,
CodexAppServerToolResultEvent,
} from "../../plugins/codex-app-server-extension-types.js";
const log = createSubsystemLogger("agents/harness");
type CodexToolResultHandler = Parameters<CodexAppServerExtensionRuntime["on"]>[1];
export function createCodexAppServerToolResultExtensionRunner(
ctx: CodexAppServerExtensionContext,
factories: CodexAppServerExtensionFactory[] = listCodexAppServerExtensionFactories(),
) {
const handlers: CodexToolResultHandler[] = [];
const runtime: CodexAppServerExtensionRuntime = {
on(event, handler) {
if (event === "tool_result") {
handlers.push(handler);
}
},
};
const initPromise = (async () => {
for (const factory of factories) {
await factory(runtime);
}
})();
return {
async applyToolResultExtensions(
event: CodexAppServerToolResultEvent,
): Promise<AgentToolResult<unknown>> {
await initPromise;
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(`[codex] tool_result extension failed for ${event.toolName}: ${detail}`);
}
}
return current;
},
};
}

View File

@@ -0,0 +1,74 @@
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { consumeAdjustedParamsForToolCall } from "../pi-tools.before-tool-call.js";
const log = createSubsystemLogger("agents/harness");
export async function runAgentHarnessAfterToolCallHook(params: {
toolName: string;
toolCallId: string;
runId?: string;
agentId?: string;
sessionId?: string;
sessionKey?: string;
startArgs: Record<string, unknown>;
result?: AgentToolResult<unknown>;
error?: string;
startedAt?: number;
}): Promise<void> {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("after_tool_call")) {
return;
}
const adjustedArgs = consumeAdjustedParamsForToolCall(params.toolCallId, params.runId);
const eventArgs =
adjustedArgs && typeof adjustedArgs === "object"
? (adjustedArgs as Record<string, unknown>)
: params.startArgs;
try {
await hookRunner.runAfterToolCall(
{
toolName: params.toolName,
params: eventArgs,
...(params.runId ? { runId: params.runId } : {}),
toolCallId: params.toolCallId,
...(params.result ? { result: params.result } : {}),
...(params.error ? { error: params.error } : {}),
...(params.startedAt != null ? { durationMs: Date.now() - params.startedAt } : {}),
},
{
toolName: params.toolName,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.runId ? { runId: params.runId } : {}),
toolCallId: params.toolCallId,
},
);
} catch (error) {
log.warn(`after_tool_call hook failed: tool=${params.toolName} error=${String(error)}`);
}
}
export function runAgentHarnessBeforeMessageWriteHook(params: {
message: AgentMessage;
agentId?: string;
sessionKey?: string;
}): AgentMessage | null {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("before_message_write")) {
return params.message;
}
const result = hookRunner.runBeforeMessageWrite(
{ message: params.message },
{
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
},
);
if (result?.block) {
return null;
}
return result?.message ?? params.message;
}

View File

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

View File

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

View File

@@ -22,6 +22,13 @@ 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 {
CodexAppServerExtensionContext,
CodexAppServerExtensionFactory,
CodexAppServerExtensionRuntime,
CodexAppServerToolResultEvent,
CodexAppServerToolResultHandlerResult,
} from "../plugins/codex-app-server-extension-types.js";
export { VERSION as OPENCLAW_VERSION } from "../version.js";
export { formatErrorMessage } from "../infra/errors.js";
@@ -59,3 +66,8 @@ export {
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
} from "../agents/harness/prompt-compaction-hook-helpers.js";
export { createCodexAppServerToolResultExtensionRunner } from "../agents/harness/codex-app-server-extensions.js";
export {
runAgentHarnessAfterToolCallHook,
runAgentHarnessBeforeMessageWriteHook,
} from "../agents/harness/hook-helpers.js";

View File

@@ -49,6 +49,7 @@ export type BuildPluginApiParams = {
| "registerCompactionProvider"
| "registerAgentHarness"
| "registerEmbeddedExtensionFactory"
| "registerCodexAppServerExtensionFactory"
| "registerDetachedTaskRuntime"
| "registerMemoryCapability"
| "registerMemoryPromptSection"
@@ -102,6 +103,8 @@ const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvi
const noopRegisterAgentHarness: OpenClawPluginApi["registerAgentHarness"] = () => {};
const noopRegisterEmbeddedExtensionFactory: OpenClawPluginApi["registerEmbeddedExtensionFactory"] =
() => {};
const noopRegisterCodexAppServerExtensionFactory: OpenClawPluginApi["registerCodexAppServerExtensionFactory"] =
() => {};
const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {};
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
@@ -171,6 +174,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerAgentHarness: handlers.registerAgentHarness ?? noopRegisterAgentHarness,
registerEmbeddedExtensionFactory:
handlers.registerEmbeddedExtensionFactory ?? noopRegisterEmbeddedExtensionFactory,
registerCodexAppServerExtensionFactory:
handlers.registerCodexAppServerExtensionFactory ?? noopRegisterCodexAppServerExtensionFactory,
registerDetachedTaskRuntime:
handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime,
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,

View File

@@ -1,6 +1,7 @@
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/types.openclaw.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";
import type { PluginRuntime } from "./runtime/types.js";
import type {
@@ -37,6 +38,7 @@ export type CapturedPluginRegistration = {
cliBackends: CliBackendPlugin[];
textTransforms: PluginTextTransformRegistration[];
embeddedExtensionFactories: ExtensionFactory[];
codexAppServerExtensionFactories: CodexAppServerExtensionFactory[];
speechProviders: SpeechProviderPlugin[];
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
@@ -60,6 +62,7 @@ export function createCapturedPluginRegistration(params?: {
const cliBackends: CliBackendPlugin[] = [];
const textTransforms: PluginTextTransformRegistration[] = [];
const embeddedExtensionFactories: ExtensionFactory[] = [];
const codexAppServerExtensionFactories: CodexAppServerExtensionFactory[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
@@ -85,6 +88,7 @@ export function createCapturedPluginRegistration(params?: {
cliBackends,
textTransforms,
embeddedExtensionFactories,
codexAppServerExtensionFactories,
speechProviders,
realtimeTranscriptionProviders,
realtimeVoiceProviders,
@@ -138,6 +142,9 @@ export function createCapturedPluginRegistration(params?: {
registerEmbeddedExtensionFactory(factory: ExtensionFactory) {
embeddedExtensionFactories.push(factory);
},
registerCodexAppServerExtensionFactory(factory: CodexAppServerExtensionFactory) {
codexAppServerExtensionFactories.push(factory);
},
registerCliBackend(backend: CliBackendPlugin) {
cliBackends.push(backend);
},

View File

@@ -0,0 +1,9 @@
import { getActivePluginRegistry } from "./runtime.js";
export const CODEX_APP_SERVER_EXTENSION_RUNTIME_ID = "codex-app-server";
export function listCodexAppServerExtensionFactories() {
return (
getActivePluginRegistry()?.codexAppServerExtensionFactories?.map((entry) => entry.factory) ?? []
);
}

View File

@@ -0,0 +1,38 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
export type CodexAppServerToolResultEvent = {
threadId: string;
turnId: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result: AgentToolResult<unknown>;
};
export type CodexAppServerExtensionContext = {
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
};
export type CodexAppServerToolResultHandlerResult = {
result: AgentToolResult<unknown>;
};
export type CodexAppServerExtensionRuntime = {
on: (
event: "tool_result",
handler: (
event: CodexAppServerToolResultEvent,
ctx: CodexAppServerExtensionContext,
) =>
| Promise<CodexAppServerToolResultHandlerResult | void>
| CodexAppServerToolResultHandlerResult
| void,
) => void;
};
export type CodexAppServerExtensionFactory = (
runtime: CodexAppServerExtensionRuntime,
) => Promise<void> | void;

View File

@@ -278,6 +278,7 @@ type PluginRegistrySnapshot = {
webFetchProviders: PluginRegistry["webFetchProviders"];
webSearchProviders: PluginRegistry["webSearchProviders"];
embeddedExtensionFactories: PluginRegistry["embeddedExtensionFactories"];
codexAppServerExtensionFactories: PluginRegistry["codexAppServerExtensionFactories"];
memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"];
agentHarnesses: PluginRegistry["agentHarnesses"];
httpRoutes: PluginRegistry["httpRoutes"];
@@ -315,6 +316,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
webFetchProviders: [...registry.webFetchProviders],
webSearchProviders: [...registry.webSearchProviders],
embeddedExtensionFactories: [...registry.embeddedExtensionFactories],
codexAppServerExtensionFactories: [...registry.codexAppServerExtensionFactories],
memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders],
agentHarnesses: [...registry.agentHarnesses],
httpRoutes: [...registry.httpRoutes],
@@ -351,6 +353,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
registry.webFetchProviders = snapshot.arrays.webFetchProviders;
registry.webSearchProviders = snapshot.arrays.webSearchProviders;
registry.embeddedExtensionFactories = snapshot.arrays.embeddedExtensionFactories;
registry.codexAppServerExtensionFactories = snapshot.arrays.codexAppServerExtensionFactories;
registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders;
registry.agentHarnesses = snapshot.arrays.agentHarnesses;
registry.httpRoutes = snapshot.arrays.httpRoutes;

View File

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

View File

@@ -4,6 +4,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
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 { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js";
import type { PluginActivationSource } from "./config-state.js";
import type {
PluginBundleFormat,
@@ -153,6 +154,14 @@ export type PluginEmbeddedExtensionFactoryRegistration = {
source: string;
rootDir?: string;
};
export type PluginCodexAppServerExtensionFactoryRegistration = {
pluginId: string;
pluginName?: string;
rawFactory: CodexAppServerExtensionFactory;
factory: CodexAppServerExtensionFactory;
source: string;
rootDir?: string;
};
export type PluginAgentHarnessRegistration = {
pluginId: string;
pluginName?: string;
@@ -291,6 +300,7 @@ export type PluginRegistry = {
webFetchProviders: PluginWebFetchProviderRegistration[];
webSearchProviders: PluginWebSearchProviderRegistration[];
embeddedExtensionFactories: PluginEmbeddedExtensionFactoryRegistration[];
codexAppServerExtensionFactories: PluginCodexAppServerExtensionFactoryRegistration[];
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
agentHarnesses: PluginAgentHarnessRegistration[];
gatewayHandlers: GatewayRequestHandlers;

View File

@@ -30,6 +30,8 @@ import {
import { resolveUserPath } from "../utils.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";
import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
import { clearPluginCommandsForPlugin } from "./command-registry-state.js";
import {
@@ -261,6 +263,69 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerCodexAppServerExtensionFactory = (
record: PluginRecord,
factory: Parameters<OpenClawPluginApi["registerCodexAppServerExtensionFactory"]>[0],
) => {
if (record.origin !== "bundled") {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "only bundled plugins can register Codex app-server extension factories",
});
return;
}
if (
!(record.contracts?.embeddedExtensionFactories ?? []).includes(
CODEX_APP_SERVER_EXTENSION_RUNTIME_ID,
)
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message:
'plugin must declare contracts.embeddedExtensionFactories: ["codex-app-server"] to register Codex app-server extension factories',
});
return;
}
if (typeof (factory as unknown) !== "function") {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "codex app-server extension factory must be a function",
});
return;
}
if (
registry.codexAppServerExtensionFactories.some(
(entry) => entry.pluginId === record.id && entry.rawFactory === factory,
)
) {
return;
}
const safeFactory: CodexAppServerExtensionFactory = async (codex) => {
try {
await factory(codex);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
registryParams.logger.warn(
`[plugins] codex app-server extension factory failed for ${record.id}: ${detail}`,
);
}
};
registry.codexAppServerExtensionFactories.push({
pluginId: record.id,
pluginName: record.name,
rawFactory: factory,
factory: safeFactory,
source: record.source,
rootDir: record.rootDir,
});
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
@@ -1339,6 +1404,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerEmbeddedExtensionFactory: (factory) => {
registerPiEmbeddedExtensionFactory(record, factory);
},
registerCodexAppServerExtensionFactory: (factory) => {
registerCodexAppServerExtensionFactory(record, factory);
},
registerMemoryCapability: (capability) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

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

View File

@@ -75,6 +75,7 @@ import type {
PluginTextReplacement,
PluginTextTransforms,
} from "./cli-backend.types.js";
import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js";
import type {
PluginConversationBinding,
PluginConversationBindingRequestParams,
@@ -2009,6 +2010,8 @@ export type OpenClawPluginApi = {
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"`. */
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 the active detached task runtime for this plugin (exclusive slot). */
registerDetachedTaskRuntime: (
runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,

View File

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

View File

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