Files
openclaw/extensions/codex/src/app-server/thread-lifecycle.ts

312 lines
11 KiB
TypeScript

import {
embeddedAgentLog,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { renderCodexPromptOverlay } from "../../prompt-overlay.js";
import { isModernCodexModel } from "../../provider.js";
import type { CodexAppServerClient } from "./client.js";
import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js";
import {
assertCodexThreadResumeResponse,
assertCodexThreadStartResponse,
} from "./protocol-validators.js";
import {
isJsonObject,
type CodexDynamicToolSpec,
type CodexThreadResumeParams,
type CodexThreadStartParams,
type CodexTurnStartParams,
type CodexUserInput,
type JsonObject,
type JsonValue,
} from "./protocol.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
export async function startOrResumeThread(params: {
client: CodexAppServerClient;
params: EmbeddedRunAttemptParams;
cwd: string;
dynamicTools: CodexDynamicToolSpec[];
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
if (binding?.threadId) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
if (
binding.dynamicToolsFingerprint &&
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
) {
embeddedAgentLog.debug(
"codex app-server dynamic tool catalog changed; starting a new thread",
{
threadId: binding.threadId,
},
);
await clearCodexAppServerBinding(params.params.sessionFile);
} else {
try {
const response = assertCodexThreadResumeResponse(
await params.client.request(
"thread/resume",
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: params.config,
}),
),
);
const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider(params.params.provider);
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
createdAt: binding.createdAt,
});
return {
...binding,
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
};
} catch (error) {
embeddedAgentLog.warn("codex app-server thread resume failed; starting a new thread", {
error,
});
await clearCodexAppServerBinding(params.params.sessionFile);
}
}
}
const modelProvider = resolveCodexAppServerModelProvider(params.params.provider);
const response = assertCodexThreadStartResponse(
await params.client.request("thread/start", {
model: params.params.modelId,
...(modelProvider ? { modelProvider } : {}),
cwd: params.cwd,
approvalPolicy: params.appServer.approvalPolicy,
approvalsReviewer: params.appServer.approvalsReviewer,
sandbox: params.appServer.sandbox,
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
serviceName: "OpenClaw",
...(params.config ? { config: params.config } : {}),
developerInstructions:
params.developerInstructions ?? buildDeveloperInstructions(params.params),
dynamicTools: params.dynamicTools,
experimentalRawEvents: true,
persistExtendedHistory: true,
} satisfies CodexThreadStartParams),
);
const createdAt = new Date().toISOString();
await writeCodexAppServerBinding(params.params.sessionFile, {
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
});
return {
schemaVersion: 1,
threadId: response.thread.id,
sessionFile: params.params.sessionFile,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
createdAt,
updatedAt: createdAt,
};
}
export function buildThreadResumeParams(
params: EmbeddedRunAttemptParams,
options: {
threadId: string;
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
},
): CodexThreadResumeParams {
const modelProvider = resolveCodexAppServerModelProvider(params.provider);
return {
threadId: options.threadId,
model: params.modelId,
...(modelProvider ? { modelProvider } : {}),
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
...(options.config ? { config: options.config } : {}),
developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params),
persistExtendedHistory: true,
};
}
export function buildTurnStartParams(
params: EmbeddedRunAttemptParams,
options: {
threadId: string;
cwd: string;
appServer: CodexAppServerRuntimeOptions;
promptText?: string;
},
): CodexTurnStartParams {
return {
threadId: options.threadId,
input: buildUserInput(params, options.promptText),
cwd: options.cwd,
approvalPolicy: options.appServer.approvalPolicy,
approvalsReviewer: options.appServer.approvalsReviewer,
sandboxPolicy: codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
model: params.modelId,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
effort: resolveReasoningEffort(params.thinkLevel, params.modelId),
};
}
function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string {
return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec));
}
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
if (!isJsonObject(tool)) {
return stabilizeJsonValue(tool);
}
const stable: JsonObject = {};
for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (key === "description") {
continue;
}
stable[key] = stabilizeJsonValue(child);
}
return stable;
}
function stabilizeJsonValue(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(stabilizeJsonValue);
}
if (!isJsonObject(value)) {
return value;
}
const stable: JsonObject = {};
for (const [key, child] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
stable[key] = stabilizeJsonValue(child);
}
return stable;
}
export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string {
const promptOverlay = renderCodexRuntimePromptOverlay(params);
const sections = [
"You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.",
"Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.",
promptOverlay,
params.extraSystemPrompt,
params.skillsSnapshot?.prompt,
];
return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n");
}
function renderCodexRuntimePromptOverlay(params: EmbeddedRunAttemptParams): string | undefined {
const contribution = params.runtimePlan?.prompt.resolveSystemPromptContribution({
config: params.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
provider: params.provider,
modelId: params.modelId,
promptMode: "full",
agentId: params.agentId,
});
if (!contribution) {
return renderCodexPromptOverlay({
config: params.config,
providerId: params.provider,
modelId: params.modelId,
});
}
return [
contribution.stablePrefix,
...Object.values(contribution.sectionOverrides ?? {}),
contribution.dynamicSuffix,
]
.filter(
(section): section is string => typeof section === "string" && section.trim().length > 0,
)
.join("\n\n");
}
function buildUserInput(
params: EmbeddedRunAttemptParams,
promptText: string = params.prompt,
): CodexUserInput[] {
return [
{ type: "text", text: promptText, text_elements: [] },
...(params.images ?? []).map(
(image): CodexUserInput => ({
type: "image",
url: `data:${image.mimeType};base64,${image.data}`,
}),
),
];
}
function resolveCodexAppServerModelProvider(provider: string): string | undefined {
const normalized = provider.trim();
if (!normalized || normalized === "codex") {
// `codex` is OpenClaw's virtual provider; let Codex app-server keep its
// native provider/auth selection instead of forcing the legacy OpenAI path.
return undefined;
}
return normalized === "openai-codex" ? "openai" : normalized;
}
// Modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) use the
// none/low/medium/high/xhigh effort enum and reject "minimal". The CLI
// defaults thinkLevel to "minimal", so without translation EVERY agent turn
// on those models pays a wasted first request + retry-with-low fallback in
// pi-embedded-runner. Map "minimal" -> "low" upfront for modern models so the
// first request is accepted. Older Codex models still accept "minimal"
// directly. (#71946)
// Exported for unit-test coverage of the model-aware translation path.
export function resolveReasoningEffort(
thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
modelId: string,
): "minimal" | "low" | "medium" | "high" | "xhigh" | null {
if (thinkLevel === "minimal") {
return isModernCodexModel(modelId) ? "low" : "minimal";
}
if (
thinkLevel === "low" ||
thinkLevel === "medium" ||
thinkLevel === "high" ||
thinkLevel === "xhigh"
) {
return thinkLevel;
}
return null;
}