mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
263
src/agents/codex-app-server.extensions.test.ts
Normal file
263
src/agents/codex-app-server.extensions.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
53
src/agents/harness/codex-app-server-extensions.ts
Normal file
53
src/agents/harness/codex-app-server-extensions.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
74
src/agents/harness/hook-helpers.ts
Normal file
74
src/agents/harness/hook-helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
9
src/plugins/codex-app-server-extension-factory.ts
Normal file
9
src/plugins/codex-app-server-extension-factory.ts
Normal 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) ?? []
|
||||
);
|
||||
}
|
||||
38
src/plugins/codex-app-server-extension-types.ts
Normal file
38
src/plugins/codex-app-server-extension-types.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -129,6 +129,7 @@ export function createPluginLoadResult(
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -42,6 +42,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerCompactionProvider() {},
|
||||
registerAgentHarness() {},
|
||||
registerEmbeddedExtensionFactory() {},
|
||||
registerCodexAppServerExtensionFactory() {},
|
||||
registerDetachedTaskRuntime() {},
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
|
||||
Reference in New Issue
Block a user