diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cac89fc51f..68882d75ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: prefer native Jiti loading for built bundled plugin dist modules on supported runtimes, cutting measured bundled plugin load time by 82-90% while keeping source TypeScript on the transform path. (#69925) Thanks @aauren. - 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. - 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. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 8ba4a80bc56..4cc72bcd8f7 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -17,6 +17,10 @@ discovery, native thread resume, native compaction, and app-server execution. OpenClaw still owns chat channels, session files, model selection, tools, approvals, media delivery, and the visible transcript mirror. +Native Codex turns also respect the shared `before_prompt_build`, +`before_compaction`, and `after_compaction` plugin hooks, so prompt shims and +compaction-aware automation can stay aligned with the PI harness. + The harness is off by default. It is selected only when the `codex` plugin is enabled and the resolved model is a `codex/*` model, or when you explicitly force `embeddedHarness.runtime: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index f3e67bd66c6..d8c65d12a0a 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -1,5 +1,14 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import type { EmbeddedRunAttemptParams } 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 { CodexAppServerEventProjector, type CodexAppServerToolTelemetry, @@ -8,36 +17,87 @@ import { createCodexTestModel } from "./test-support.js"; const THREAD_ID = "thread-1"; const TURN_ID = "turn-1"; +const tempDirs = new Set(); type ProjectorNotification = Parameters[0]; -function createParams(): EmbeddedRunAttemptParams { +function assistantMessage(text: string, timestamp: number) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, 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" as const, + timestamp, + }; +} + +async function createParams(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-")); + tempDirs.add(tempDir); + const sessionFile = path.join(tempDir, "session.jsonl"); + SessionManager.open(sessionFile).appendMessage(assistantMessage("history", Date.now())); return { prompt: "hello", sessionId: "session-1", + sessionFile, + workspaceDir: tempDir, + runId: "run-1", provider: "openai-codex", modelId: "gpt-5.4-codex", model: createCodexTestModel(), thinkLevel: "medium", - } as unknown as EmbeddedRunAttemptParams; + } as EmbeddedRunAttemptParams; } -function createProjector(params = createParams()): CodexAppServerEventProjector { - return new CodexAppServerEventProjector(params, THREAD_ID, TURN_ID); +async function createProjector( + params?: EmbeddedRunAttemptParams, +): Promise { + const resolvedParams = params ?? (await createParams()); + return new CodexAppServerEventProjector(resolvedParams, THREAD_ID, TURN_ID); } -function createProjectorWithAssistantHooks() { +async function createProjectorWithAssistantHooks() { const onAssistantMessageStart = vi.fn(); const onPartialReply = vi.fn(); - return { + const params = await createParams(); + const projector = await createProjector({ + ...params, onAssistantMessageStart, onPartialReply, - projector: createProjector({ - ...createParams(), - onAssistantMessageStart, - onPartialReply, - }), - }; + }); + return { onAssistantMessageStart, onPartialReply, projector }; +} + +afterEach(async () => { + resetGlobalHookRunner(); + vi.restoreAllMocks(); + for (const tempDir of tempDirs) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +async function createProjectorWithHooks() { + const beforeCompaction = vi.fn(); + const afterCompaction = vi.fn(); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_compaction", handler: beforeCompaction }, + { hookName: "after_compaction", handler: afterCompaction }, + ]), + ); + const projector = await createProjector(); + return { projector, beforeCompaction, afterCompaction }; } function buildEmptyToolTelemetry(): CodexAppServerToolTelemetry { @@ -72,7 +132,7 @@ function turnCompleted(items: unknown[] = []): ProjectorNotification { describe("CodexAppServerEventProjector", () => { it("projects assistant deltas and usage into embedded attempt results", async () => { const { onAssistantMessageStart, onPartialReply, projector } = - createProjectorWithAssistantHooks(); + await createProjectorWithAssistantHooks(); await projector.handleNotification(agentMessageDelta("hel")); await projector.handleNotification(agentMessageDelta("lo")); @@ -116,7 +176,7 @@ describe("CodexAppServerEventProjector", () => { }); it("does not treat cumulative-only token usage as fresh context usage", async () => { - const projector = createProjector(); + const projector = await createProjector(); await projector.handleNotification(agentMessageDelta("done")); await projector.handleNotification( @@ -145,7 +205,7 @@ describe("CodexAppServerEventProjector", () => { }); it("normalizes snake_case current token usage fields", async () => { - const projector = createProjector(); + const projector = await createProjector(); await projector.handleNotification(agentMessageDelta("done")); await projector.handleNotification( @@ -175,7 +235,7 @@ describe("CodexAppServerEventProjector", () => { it("keeps intermediate agentMessage items out of the final visible reply", async () => { const { onAssistantMessageStart, onPartialReply, projector } = - createProjectorWithAssistantHooks(); + await createProjectorWithAssistantHooks(); await projector.handleNotification( agentMessageDelta( @@ -221,7 +281,7 @@ describe("CodexAppServerEventProjector", () => { }); it("ignores notifications for other turns", async () => { - const projector = createProjector(); + const projector = await createProjector(); await projector.handleNotification({ method: "item/agentMessage/delta", @@ -233,7 +293,21 @@ describe("CodexAppServerEventProjector", () => { }); it("preserves sessions_yield detection in attempt results", () => { - const projector = createProjector(); + const projector = new CodexAppServerEventProjector( + { + prompt: "hello", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + runId: "run-1", + provider: "openai-codex", + modelId: "gpt-5.4-codex", + model: createCodexTestModel(), + thinkLevel: "medium", + } as EmbeddedRunAttemptParams, + THREAD_ID, + TURN_ID, + ); const result = projector.buildResult(buildEmptyToolTelemetry(), { yieldDetected: true }); @@ -245,12 +319,12 @@ describe("CodexAppServerEventProjector", () => { const onReasoningEnd = vi.fn(); const onAgentEvent = vi.fn(); const params = { - ...createParams(), + ...(await createParams()), onReasoningStream, onReasoningEnd, onAgentEvent, }; - const projector = createProjector(params); + const projector = await createProjector(params); await projector.handleNotification( forCurrentTurn("item/reasoning/textDelta", { itemId: "reason-1", delta: "thinking" }), @@ -319,8 +393,8 @@ describe("CodexAppServerEventProjector", () => { const onAgentEvent = vi.fn(() => { throw new Error("consumer failed"); }); - const projector = createProjector({ - ...createParams(), + const projector = await createProjector({ + ...(await createParams()), onAgentEvent, }); @@ -344,4 +418,42 @@ describe("CodexAppServerEventProjector", () => { expect(result.assistantTexts).toEqual(["final answer"]); expect(JSON.stringify(result.messagesSnapshot)).toContain("Codex plan"); }); + + it("fires before_compaction and after_compaction hooks for codex compaction items", async () => { + const { projector, beforeCompaction, afterCompaction } = await createProjectorWithHooks(); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { type: "contextCompaction", id: "compact-1" }, + }), + ); + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { type: "contextCompaction", id: "compact-1" }, + }), + ); + + expect(beforeCompaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageCount: 1, + sessionFile: expect.stringContaining("session.jsonl"), + messages: [expect.objectContaining({ role: "assistant" })], + }), + expect.objectContaining({ + runId: "run-1", + sessionId: "session-1", + }), + ); + expect(afterCompaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageCount: 1, + compactedCount: -1, + sessionFile: expect.stringContaining("session.jsonl"), + }), + expect.objectContaining({ + runId: "run-1", + sessionId: "session-1", + }), + ); + }); }); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 79fe4b4dec9..8707c06d017 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -1,9 +1,11 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Usage } from "@mariozechner/pi-ai"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { formatErrorMessage, normalizeUsage, - type NormalizedUsage, + runAgentHarnessAfterCompactionHook, + runAgentHarnessBeforeCompactionHook, type EmbeddedRunAttemptParams, type EmbeddedRunAttemptResult, type MessagingToolSend, @@ -67,7 +69,7 @@ export class CodexAppServerEventProjector { private promptError: unknown; private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null; private aborted = false; - private tokenUsage: NormalizedUsage | undefined; + private tokenUsage: ReturnType; private guardianReviewCount = 0; private completedCompactionCount = 0; @@ -98,10 +100,10 @@ export class CodexAppServerEventProjector { this.handleTurnPlanUpdated(params); break; case "item/started": - this.handleItemStarted(params); + await this.handleItemStarted(params); break; case "item/completed": - this.handleItemCompleted(params); + await this.handleItemCompleted(params); break; case "item/autoApprovalReview/started": case "item/autoApprovalReview/completed": @@ -271,7 +273,7 @@ export class CodexAppServerEventProjector { }); } - private handleItemStarted(params: JsonObject): void { + private async handleItemStarted(params: JsonObject): Promise { const item = readItem(params.item); const itemId = item?.id ?? readString(params, "itemId") ?? readString(params, "id"); if (itemId) { @@ -279,6 +281,20 @@ export class CodexAppServerEventProjector { } if (item?.type === "contextCompaction" && itemId) { this.activeCompactionItemIds.add(itemId); + await runAgentHarnessBeforeCompactionHook({ + sessionFile: this.params.sessionFile, + messages: this.readMirroredSessionMessages(), + ctx: { + runId: this.params.runId, + agentId: this.params.agentId, + sessionKey: this.params.sessionKey, + sessionId: this.params.sessionId, + workspaceDir: this.params.workspaceDir, + messageProvider: this.params.messageProvider ?? undefined, + trigger: this.params.trigger, + channelId: this.params.messageChannel ?? this.params.messageProvider ?? undefined, + }, + }); this.emitAgentEvent({ stream: "compaction", data: { @@ -297,7 +313,7 @@ export class CodexAppServerEventProjector { }); } - private handleItemCompleted(params: JsonObject): void { + private async handleItemCompleted(params: JsonObject): Promise { const item = readItem(params.item); const itemId = item?.id ?? readString(params, "itemId") ?? readString(params, "id"); if (itemId) { @@ -315,6 +331,21 @@ export class CodexAppServerEventProjector { if (item?.type === "contextCompaction" && itemId) { this.activeCompactionItemIds.delete(itemId); this.completedCompactionCount += 1; + await runAgentHarnessAfterCompactionHook({ + sessionFile: this.params.sessionFile, + messages: this.readMirroredSessionMessages(), + compactedCount: -1, + ctx: { + runId: this.params.runId, + agentId: this.params.agentId, + sessionKey: this.params.sessionKey, + sessionId: this.params.sessionId, + workspaceDir: this.params.workspaceDir, + messageProvider: this.params.messageProvider ?? undefined, + trigger: this.params.trigger, + channelId: this.params.messageChannel ?? this.params.messageProvider ?? undefined, + }, + }); this.emitAgentEvent({ stream: "compaction", data: { @@ -476,6 +507,14 @@ export class CodexAppServerEventProjector { this.assistantItemOrder.push(itemId); } + private readMirroredSessionMessages(): AgentMessage[] { + try { + return SessionManager.open(this.params.sessionFile).buildSessionContext().messages; + } catch { + return []; + } + } + private createAssistantMessage(text: string): AssistantMessage { const usage: Usage = this.tokenUsage ? { @@ -563,7 +602,7 @@ function readNumberAlias(record: JsonObject, keys: readonly string[]): number | return undefined; } -function normalizeCodexTokenUsage(record: JsonObject): NormalizedUsage | undefined { +function normalizeCodexTokenUsage(record: JsonObject): ReturnType { return normalizeUsage({ input: readNumberAlias(record, ["inputTokens", "input_tokens", "input", "promptTokens"]), output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]), diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 1af77125550..9c3f993250f 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1,12 +1,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { abortAgentHarnessRun, queueAgentHarnessMessage, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness"; import { afterEach, beforeEach, 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 { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; import type { CodexServerNotification } from "./protocol.js"; import { runCodexAppServerAttempt, __testing } from "./run-attempt.js"; @@ -47,6 +53,26 @@ function turnStartResult(turnId = "turn-1", status = "inProgress") { return { turn: { id: turnId, status } }; } +function assistantMessage(text: string, timestamp: number) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, 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" as const, + timestamp, + }; +} + function createAppServerHarness( requestImpl: (method: string, params: unknown) => Promise, options: { onStart?: (authProfileId: string | undefined) => void } = {}, @@ -157,10 +183,61 @@ describe("runCodexAppServerAttempt", () => { afterEach(async () => { __testing.resetCodexAppServerClientFactoryForTests(); + resetGlobalHookRunner(); vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); }); + it("applies before_prompt_build to Codex developer instructions and turn input", async () => { + const beforePromptBuild = vi.fn(async () => ({ + systemPrompt: "custom codex system", + prependSystemContext: "pre system", + appendSystemContext: "post system", + prependContext: "queued context", + })); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]), + ); + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage(assistantMessage("previous turn", Date.now())); + const harness = createStartedThreadHarness(); + + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + expect(beforePromptBuild).toHaveBeenCalledWith( + { + prompt: "hello", + messages: [expect.objectContaining({ role: "assistant" })], + }, + expect.objectContaining({ + runId: "run-1", + sessionId: "session-1", + }), + ); + expect(harness.requests).toEqual( + expect.arrayContaining([ + { + method: "thread/start", + params: expect.objectContaining({ + developerInstructions: expect.stringContaining("pre system\n\ncustom codex system"), + }), + }, + { + method: "turn/start", + params: expect.objectContaining({ + input: [{ type: "text", text: "queued context\n\nhello" }], + }), + }, + ]), + ); + }); + it("forwards queued user input and aborts the active app-server turn", async () => { const { requests, waitForMethod } = createStartedThreadHarness(); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 67eb23f294b..72d16beab6f 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { buildEmbeddedAttemptToolRunContext, clearActiveEmbeddedRun, @@ -12,6 +13,7 @@ import { resolveSandboxContext, resolveSessionAgentIds, resolveUserPath, + resolveAgentHarnessBeforePromptBuildResult, setActiveEmbeddedRun, supportsModelTools, type EmbeddedRunAttemptParams, @@ -36,7 +38,11 @@ import { } from "./protocol.js"; import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js"; import { clearSharedCodexAppServerClient } from "./shared-client.js"; -import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js"; +import { + buildDeveloperInstructions, + buildTurnStartParams, + startOrResumeThread, +} from "./thread-lifecycle.js"; import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; let clientFactory = defaultCodexAppServerClientFactory; @@ -95,6 +101,22 @@ export async function runCodexAppServerAttempt( tools, signal: runAbortController.signal, }); + const historyMessages = readMirroredSessionHistoryMessages(params.sessionFile); + const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ + prompt: params.prompt, + developerInstructions: buildDeveloperInstructions(params), + messages: historyMessages, + ctx: { + runId: params.runId, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider ?? undefined, + }, + }); let client: CodexAppServerClient; let thread: CodexAppServerThreadBinding; try { @@ -110,6 +132,7 @@ export async function runCodexAppServerAttempt( cwd: effectiveWorkspace, dynamicTools: toolBridge.specs, appServer, + developerInstructions: promptBuild.developerInstructions, }); return { client: startupClient, thread: startupThread }; }, @@ -196,6 +219,7 @@ export async function runCodexAppServerAttempt( threadId: thread.threadId, cwd: effectiveWorkspace, appServer, + promptText: promptBuild.prompt, }), { timeoutMs: params.timeoutMs, signal: runAbortController.signal }, ); @@ -476,6 +500,18 @@ function readString(record: JsonObject, key: string): string | undefined { return typeof value === "string" ? value : undefined; } +function readMirroredSessionHistoryMessages(sessionFile: string): unknown[] { + try { + return SessionManager.open(sessionFile).buildSessionContext().messages; + } catch (error) { + embeddedAgentLog.warn("failed to read mirrored session history for codex prompt hooks", { + error, + sessionFile, + }); + return []; + } +} + async function mirrorTranscriptBestEffort(params: { params: EmbeddedRunAttemptParams; result: EmbeddedRunAttemptResult; diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index b7292b8ee2c..61e4ba5742a 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -25,6 +25,7 @@ export async function startOrResumeThread(params: { cwd: string; dynamicTools: JsonValue[]; appServer: CodexAppServerRuntimeOptions; + developerInstructions?: string; }): Promise { const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools); const binding = await readCodexAppServerBinding(params.params.sessionFile); @@ -49,6 +50,7 @@ export async function startOrResumeThread(params: { buildThreadResumeParams(params.params, { threadId: binding.threadId, appServer: params.appServer, + developerInstructions: params.developerInstructions, }), ); const boundAuthProfileId = params.params.authProfileId ?? binding.authProfileId; @@ -88,7 +90,8 @@ export async function startOrResumeThread(params: { sandbox: params.appServer.sandbox, ...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}), serviceName: "OpenClaw", - developerInstructions: buildDeveloperInstructions(params.params), + developerInstructions: + params.developerInstructions ?? buildDeveloperInstructions(params.params), dynamicTools: params.dynamicTools, experimentalRawEvents: true, persistExtendedHistory: true, @@ -122,6 +125,7 @@ export function buildThreadResumeParams( options: { threadId: string; appServer: CodexAppServerRuntimeOptions; + developerInstructions?: string; }, ): CodexThreadResumeParams { return { @@ -132,7 +136,7 @@ export function buildThreadResumeParams( approvalsReviewer: options.appServer.approvalsReviewer, sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), - developerInstructions: buildDeveloperInstructions(params), + developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), persistExtendedHistory: true, }; } @@ -143,11 +147,12 @@ export function buildTurnStartParams( threadId: string; cwd: string; appServer: CodexAppServerRuntimeOptions; + promptText?: string; }, ): CodexTurnStartParams { return { threadId: options.threadId, - input: buildUserInput(params), + input: buildUserInput(params, options.promptText), cwd: options.cwd, approvalPolicy: options.appServer.approvalPolicy, approvalsReviewer: options.appServer.approvalsReviewer, @@ -177,7 +182,7 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { return stable; } -function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { +export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { 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.", @@ -188,9 +193,12 @@ function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { return sections.filter((section) => typeof section === "string" && section.trim()).join("\n\n"); } -function buildUserInput(params: EmbeddedRunAttemptParams): CodexUserInput[] { +function buildUserInput( + params: EmbeddedRunAttemptParams, + promptText: string = params.prompt, +): CodexUserInput[] { return [ - { type: "text", text: params.prompt }, + { type: "text", text: promptText }, ...(params.images ?? []).map( (image): CodexUserInput => ({ type: "image", diff --git a/src/agents/harness/prompt-compaction-hook-helpers.ts b/src/agents/harness/prompt-compaction-hook-helpers.ts new file mode 100644 index 00000000000..53f35a3d65e --- /dev/null +++ b/src/agents/harness/prompt-compaction-hook-helpers.ts @@ -0,0 +1,156 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import type { + PluginHookAgentContext, + PluginHookBeforeAgentStartResult, + PluginHookBeforePromptBuildResult, +} from "../../plugins/types.js"; +import { joinPresentTextSegments } from "../../shared/text/join-segments.js"; + +const log = createSubsystemLogger("agents/harness"); + +type AgentHarnessHookContext = { + runId: string; + agentId?: string; + sessionKey?: string; + sessionId?: string; + workspaceDir?: string; + messageProvider?: string; + trigger?: string; + channelId?: string; +}; + +export type AgentHarnessPromptBuildResult = { + prompt: string; + developerInstructions: string; +}; + +function buildAgentHookContext(params: AgentHarnessHookContext): PluginHookAgentContext { + return { + runId: params.runId, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.messageProvider ? { messageProvider: params.messageProvider } : {}), + ...(params.trigger ? { trigger: params.trigger } : {}), + ...(params.channelId ? { channelId: params.channelId } : {}), + }; +} + +export async function resolveAgentHarnessBeforePromptBuildResult(params: { + prompt: string; + developerInstructions: string; + messages: unknown[]; + ctx: AgentHarnessHookContext; +}): Promise { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("before_prompt_build") && !hookRunner?.hasHooks("before_agent_start")) { + return { + prompt: params.prompt, + developerInstructions: params.developerInstructions, + }; + } + const hookCtx = buildAgentHookContext(params.ctx); + const promptEvent = { + prompt: params.prompt, + messages: params.messages, + }; + + const promptBuildResult = hookRunner.hasHooks("before_prompt_build") + ? await hookRunner.runBeforePromptBuild(promptEvent, hookCtx).catch((error) => { + log.warn(`before_prompt_build hook failed: ${String(error)}`); + return undefined; + }) + : undefined; + const legacyResult = hookRunner.hasHooks("before_agent_start") + ? await hookRunner.runBeforeAgentStart(promptEvent, hookCtx).catch((error) => { + log.warn(`before_agent_start hook (legacy prompt build path) failed: ${String(error)}`); + return undefined; + }) + : undefined; + + const systemPrompt = resolvePromptBuildSystemPrompt({ + developerInstructions: params.developerInstructions, + promptBuildResult, + legacyResult, + }); + return { + prompt: + joinPresentTextSegments([ + promptBuildResult?.prependContext, + legacyResult?.prependContext, + params.prompt, + ]) ?? params.prompt, + developerInstructions: + joinPresentTextSegments([ + promptBuildResult?.prependSystemContext, + legacyResult?.prependSystemContext, + systemPrompt, + promptBuildResult?.appendSystemContext, + legacyResult?.appendSystemContext, + ]) ?? systemPrompt, + }; +} + +function resolvePromptBuildSystemPrompt(params: { + developerInstructions: string; + promptBuildResult?: PluginHookBeforePromptBuildResult; + legacyResult?: PluginHookBeforeAgentStartResult; +}): string { + if (typeof params.promptBuildResult?.systemPrompt === "string") { + return params.promptBuildResult.systemPrompt; + } + if (typeof params.legacyResult?.systemPrompt === "string") { + return params.legacyResult.systemPrompt; + } + return params.developerInstructions; +} + +export async function runAgentHarnessBeforeCompactionHook(params: { + sessionFile: string; + messages: AgentMessage[]; + ctx: AgentHarnessHookContext; +}): Promise { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("before_compaction")) { + return; + } + try { + await hookRunner.runBeforeCompaction( + { + messageCount: params.messages.length, + messages: params.messages, + sessionFile: params.sessionFile, + }, + buildAgentHookContext(params.ctx), + ); + } catch (error) { + log.warn(`before_compaction hook failed: ${String(error)}`); + } +} + +export async function runAgentHarnessAfterCompactionHook(params: { + sessionFile: string; + messages: AgentMessage[]; + ctx: AgentHarnessHookContext; + compactedCount: number; +}): Promise { + const hookRunner = getGlobalHookRunner(); + if (!hookRunner?.hasHooks("after_compaction")) { + return; + } + try { + await hookRunner.runAfterCompaction( + { + messageCount: params.messages.length, + compactedCount: params.compactedCount, + sessionFile: params.sessionFile, + }, + buildAgentHookContext(params.ctx), + ); + } catch (error) { + log.warn(`after_compaction hook failed: ${String(error)}`); + } +} diff --git a/src/plugin-sdk/agent-harness.ts b/src/plugin-sdk/agent-harness.ts index 795f78bad16..bbe4d26b775 100644 --- a/src/plugin-sdk/agent-harness.ts +++ b/src/plugin-sdk/agent-harness.ts @@ -54,3 +54,8 @@ export { resolveSandboxContext } from "../agents/sandbox.js"; export { isSubagentSessionKey } from "../routing/session-key.js"; export { acquireSessionWriteLock } from "../agents/session-write-lock.js"; export { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; +export { + resolveAgentHarnessBeforePromptBuildResult, + runAgentHarnessAfterCompactionHook, + runAgentHarnessBeforeCompactionHook, +} from "../agents/harness/prompt-compaction-hook-helpers.js";