Files
openclaw/extensions/codex/src/app-server/dynamic-tools.ts
Vincent Koc a5128777ee 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
2026-04-22 16:18:10 -07:00

261 lines
7.9 KiB
TypeScript

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";
import {
type CodexDynamicToolCallOutputContentItem,
type CodexDynamicToolCallParams,
type CodexDynamicToolCallResponse,
type CodexDynamicToolSpec,
type JsonValue,
} from "./protocol.js";
export type CodexDynamicToolBridge = {
specs: CodexDynamicToolSpec[];
handleToolCall: (params: CodexDynamicToolCallParams) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
toolMediaUrls: string[];
toolAudioAsVoice: boolean;
successfulCronAdds?: number;
};
};
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"] = {
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
toolMediaUrls: [],
toolAudioAsVoice: false,
};
const extensionRunner = createCodexAppServerToolResultExtensionRunner(params.hookContext ?? {});
return {
specs: params.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: toJsonValue(tool.parameters),
})),
telemetry,
handleToolCall: async (call) => {
const tool = toolMap.get(call.tool);
if (!tool) {
return {
contentItems: [{ type: "inputText", text: `Unknown OpenClaw tool: ${call.tool}` }],
success: false,
};
}
const args = jsonObjectToRecord(call.arguments);
const startedAt = Date.now();
try {
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
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,
result,
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,
};
} catch (error) {
collectToolTelemetry({
toolName: tool.name,
args,
result: undefined,
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: [
{
type: "inputText",
text: error instanceof Error ? error.message : String(error),
},
],
success: false,
};
}
},
};
}
function collectToolTelemetry(params: {
toolName: string;
args: Record<string, unknown>;
result: AgentToolResult<unknown> | undefined;
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
}): void {
if (params.isError) {
return;
}
if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) {
params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1;
}
if (!params.isError && params.result) {
const media = extractToolResultMediaArtifact(params.result);
if (media) {
const mediaUrls = filterToolResultMediaUrls(params.toolName, media.mediaUrls, params.result);
const seen = new Set(params.telemetry.toolMediaUrls);
for (const mediaUrl of mediaUrls) {
if (!seen.has(mediaUrl)) {
seen.add(mediaUrl);
params.telemetry.toolMediaUrls.push(mediaUrl);
}
}
if (media.audioAsVoice) {
params.telemetry.toolAudioAsVoice = true;
}
}
}
if (
!isMessagingTool(params.toolName) ||
!isMessagingToolSendAction(params.toolName, params.args)
) {
return;
}
params.telemetry.didSendViaMessagingTool = true;
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
if (text) {
params.telemetry.messagingToolSentTexts.push(text);
}
params.telemetry.messagingToolSentMediaUrls.push(...collectMediaUrls(params.args));
params.telemetry.messagingToolSentTargets.push({
tool: params.toolName,
provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName,
accountId: readFirstString(params.args, ["accountId", "account_id"]),
to: readFirstString(params.args, ["to", "target", "recipient"]),
threadId: readFirstString(params.args, ["threadId", "thread_id", "messageThreadId"]),
});
}
function convertToolContent(
content: TextContent | ImageContent,
): CodexDynamicToolCallOutputContentItem[] {
if (content.type === "text") {
return [{ type: "inputText", text: content.text }];
}
return [
{
type: "inputImage",
imageUrl: `data:${content.mimeType};base64,${content.data}`,
},
];
}
function toJsonValue(value: unknown): JsonValue {
try {
const text = JSON.stringify(value);
if (!text) {
return {};
}
return JSON.parse(text) as JsonValue;
} catch {
return {};
}
}
function jsonObjectToRecord(value: JsonValue | undefined): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function readFirstString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
return undefined;
}
function collectMediaUrls(record: Record<string, unknown>): string[] {
const urls: string[] = [];
for (const key of ["mediaUrl", "media_url", "imageUrl", "image_url"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
urls.push(value.trim());
}
}
for (const key of ["mediaUrls", "media_urls", "imageUrls", "image_urls"]) {
const value = record[key];
if (!Array.isArray(value)) {
continue;
}
for (const entry of value) {
if (typeof entry === "string" && entry.trim()) {
urls.push(entry.trim());
}
}
}
return urls;
}
function isCronAddAction(args: Record<string, unknown>): boolean {
const action = args.action;
return typeof action === "string" && action.trim().toLowerCase() === "add";
}