diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 29f1ed94d0b..d4414089c82 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f5236ba3f34837485d1e319262d4d73ecd46ea8890d3f4c26a069834f376b796 config-baseline.json -484b36513ecb4a13cc945c3916fbe5ac712b5e0ab2c4ffa2dc811758da4ec7a6 config-baseline.core.json +15a3740b57d0c95f0c0963c1d0eff6d85ecdb8cb03960b4763e847f8a24551c0 config-baseline.json +3c39a3a2008ce938886b600e9429a71921c1f9b00c64a16801f47d6d8d2ad7a8 config-baseline.core.json 7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json -17eb3f8887193579ff32e35f9bd520ba2bd6049e52ab18855c5d41fcbf195d83 config-baseline.plugin.json +9e131d7734f8b9cc9e7f8af6cc6b6dc81c9971dc551fadbe66fb0d682173f32d config-baseline.plugin.json diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 0c3eb7465d5..5976c44ce1f 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -126,6 +126,11 @@ Each event includes: `type`, `action`, `sessionKey`, `timestamp`, `messages` (pu **Compaction events**: `session:compact:before` includes `messageCount`, `tokenCount`. `session:compact:after` adds `compactedCount`, `summaryLength`, `tokensBefore`, `tokensAfter`. +`command:stop` observes the user issuing `/stop`; it is cancellation/command +lifecycle, not an agent-finalization gate. Plugins that need to inspect a +natural final answer and ask the agent for one more pass should use the typed +plugin hook `before_agent_finalize` instead. See [Plugin hooks](/plugins/hooks). + ## Hook discovery Hooks are discovered from these directories, in order of increasing override precedence: diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 8e72120c207..61ac4c19e85 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -219,7 +219,8 @@ For runtime hook debugging: from a module-loaded inspection pass. - `openclaw gateway status --deep --require-rpc` confirms the reachable Gateway, service/process hints, config path, and RPC health. -- Non-bundled conversation hooks (`llm_input`, `llm_output`, `agent_end`) require +- Non-bundled conversation hooks (`llm_input`, `llm_output`, + `before_agent_finalize`, `agent_end`) require `plugins.entries..hooks.allowConversationAccess=true`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b4551df679b..3628b84a096 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -160,7 +160,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. -- `plugins.entries..hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. +- `plugins.entries..hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. - `plugins.entries..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index a0b0ca8487f..998efdd5cf4 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -34,6 +34,7 @@ These are in-process OpenClaw hooks, not Codex `hooks.json` command hooks: - `llm_input`, `llm_output` - `before_tool_call`, `after_tool_call` - `before_message_write` for mirrored transcript records +- `before_agent_finalize` through Codex `Stop` relay - `agent_end` Plugins can also register runtime-neutral tool-result middleware to rewrite @@ -583,10 +584,10 @@ The Codex harness has three hook layers: OpenClaw does not use project or global Codex `hooks.json` files to route OpenClaw plugin behavior. For the supported native tool and permission bridge, -OpenClaw injects per-thread Codex config for `PreToolUse`, `PostToolUse`, and -`PermissionRequest`. Other Codex hooks such as `SessionStart`, -`UserPromptSubmit`, and `Stop` remain Codex-level controls; they are not exposed -as OpenClaw plugin hooks in the v1 contract. +OpenClaw injects per-thread Codex config for `PreToolUse`, `PostToolUse`, +`PermissionRequest`, and `Stop`. Other Codex hooks such as `SessionStart` and +`UserPromptSubmit` remain Codex-level controls; they are not exposed as +OpenClaw plugin hooks in the v1 contract. For OpenClaw dynamic tools, OpenClaw executes the tool after Codex asks for the call, so OpenClaw fires the plugin and middleware behavior it owns in the @@ -622,6 +623,7 @@ Supported in Codex runtime v1: | Context engine lifecycle | Supported | Assemble, ingest or after-turn maintenance, and context-engine compaction coordination run for Codex turns. | | Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. | | Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. | +| Final-answer revision gate | Supported through the native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. | | Native shell, patch, and MCP block or observe | Supported through the native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on Codex app-server `0.125.0` or newer. Blocking is supported; argument rewriting is not. | | Native permission policy | Supported through the native hook relay | Codex `PermissionRequest` can be routed through OpenClaw policy where the runtime exposes it. If OpenClaw returns no decision, Codex continues through its normal guardian or user approval path. | | App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. | @@ -635,7 +637,6 @@ Not supported in Codex runtime v1: | `tool_result_persist` for Codex-native tool records | That hook transforms OpenClaw-owned transcript writes, not Codex-native tool records. | Could mirror transformed records, but canonical rewrite needs Codex support. | | Rich native compaction metadata | OpenClaw observes compaction start and completion, but does not receive a stable kept/dropped list, token delta, or summary payload. | Needs richer Codex compaction events. | | Compaction intervention | Current OpenClaw compaction hooks are notification-level in Codex mode. | Add Codex pre/post compaction hooks if plugins need to veto or rewrite native compaction. | -| Stop or final-answer gating | Codex has native stop hooks, but OpenClaw does not expose final-answer gating as a v1 plugin contract. | Future opt-in primitive with loop and timeout safeguards. | | Byte-for-byte model API request capture | OpenClaw can capture app-server requests and notifications, but Codex core builds the final OpenAI API request internally. | Needs a Codex model-request tracing event or debug API. | ## Tools, media, and compaction diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 89d9763d3ec..abd5a3e2e28 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -64,6 +64,7 @@ observation-only. - `before_prompt_build` — add dynamic context or system-prompt text before the model call - `before_agent_start` — compatibility-only combined phase; prefer the two hooks above - **`before_agent_reply`** — short-circuit the model turn with a synthetic reply or silence +- **`before_agent_finalize`** — inspect the natural final answer and request one more model pass - `agent_end` — observe final messages, success state, and run duration **Conversation observation** @@ -185,7 +186,16 @@ bodies, or provider request IDs. These hooks include stable metadata such as `durationMs`/`outcome`, and `upstreamRequestIdHash` when OpenClaw can derive a bounded provider request-id hash. -Non-bundled plugins that need `llm_input`, `llm_output`, or `agent_end` must set: +`before_agent_finalize` runs only when a harness is about to accept a natural +final assistant answer. It is not the `/stop` cancellation path and does not +run when the user aborts a turn. Return `{ action: "revise", reason }` to ask +the harness for one more model pass before finalization, `{ action: +"finalize", reason? }` to force finalization, or omit a result to continue. +Codex native `Stop` hooks are relayed into this hook as OpenClaw +`before_agent_finalize` decisions. + +Non-bundled plugins that need `llm_input`, `llm_output`, +`before_agent_finalize`, or `agent_end` must set: ```json { diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 977713904d4..3ee993712bb 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -65,11 +65,12 @@ dynamic tools still execute through OpenClaw, while Codex-native tools such as shell/apply-patch execute inside Codex. For Codex-native tool events, OpenClaw injects a per-turn native hook relay so plugin hooks can block `before_tool_call`, observe `after_tool_call`, and route Codex -`PermissionRequest` events through OpenClaw approvals. The v1 relay is -deliberately conservative: it does not mutate Codex-native tool arguments, -rewrite Codex thread records, or gate final answers/Stop hooks. Use explicit -ACP only when you want the ACP runtime/session model. The embedded Codex support -boundary is documented in the +`PermissionRequest` events through OpenClaw approvals. Codex `Stop` hooks are +relayed to OpenClaw `before_agent_finalize`, where plugins can request one more +model pass before Codex finalizes its answer. The relay remains deliberately +conservative: it does not mutate Codex-native tool arguments or rewrite Codex +thread records. Use explicit ACP only when you want the ACP runtime/session +model. The embedded Codex support boundary is documented in the [Codex harness v1 support contract](/plugins/codex-harness#v1-support-contract). Natural-language triggers that should route to the ACP runtime: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a4eda7a8877..4f8a2777a26 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -223,7 +223,7 @@ do not run in live chat traffic, check these first: `openclaw gateway run` process. - Use `openclaw plugins inspect --json` to confirm hook registrations and diagnostics. Non-bundled conversation hooks such as `llm_input`, - `llm_output`, and `agent_end` need + `llm_output`, `before_agent_finalize`, and `agent_end` need `plugins.entries..hooks.allowConversationAccess=true`. - For model switching, prefer `before_model_resolve`. It runs before model resolution for agent turns; `llm_output` only runs after a model attempt diff --git a/extensions/codex/src/app-server/native-hook-relay.test.ts b/extensions/codex/src/app-server/native-hook-relay.test.ts index df98cf9e633..057a188e634 100644 --- a/extensions/codex/src/app-server/native-hook-relay.test.ts +++ b/extensions/codex/src/app-server/native-hook-relay.test.ts @@ -59,11 +59,25 @@ describe("Codex native hook relay config", () => { ], }, ], + "hooks.Stop": [ + { + matcher: null, + hooks: [ + { + type: "command", + command: + "openclaw hooks relay --provider codex --relay-id relay-1 --event before_agent_finalize", + timeout: 7, + async: false, + statusMessage: "OpenClaw native hook relay", + }, + ], + }, + ], }); expect(JSON.stringify(config)).not.toContain("timeoutSec"); expect(config).not.toHaveProperty("hooks.SessionStart"); expect(config).not.toHaveProperty("hooks.UserPromptSubmit"); - expect(config).not.toHaveProperty("hooks.Stop"); }); it("includes only requested hook events", () => { @@ -108,6 +122,7 @@ describe("Codex native hook relay config", () => { "hooks.PreToolUse": [], "hooks.PostToolUse": [], "hooks.PermissionRequest": [], + "hooks.Stop": [], }); }); }); @@ -119,7 +134,7 @@ function createRelay(): NativeHookRelayRegistrationHandle { sessionId: "session-1", sessionKey: "agent:main:session-1", runId: "run-1", - allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request"], + allowedEvents: ["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"], expiresAtMs: Date.now() + 1000, commandForEvent: (event) => `openclaw hooks relay --provider codex --relay-id relay-1 --event ${event}`, diff --git a/extensions/codex/src/app-server/native-hook-relay.ts b/extensions/codex/src/app-server/native-hook-relay.ts index 44ee0db6c13..860693bd696 100644 --- a/extensions/codex/src/app-server/native-hook-relay.ts +++ b/extensions/codex/src/app-server/native-hook-relay.ts @@ -8,14 +8,16 @@ export const CODEX_NATIVE_HOOK_RELAY_EVENTS = [ "pre_tool_use", "post_tool_use", "permission_request", + "before_agent_finalize", ] as const satisfies readonly NativeHookRelayEvent[]; -type CodexHookEventName = "PreToolUse" | "PostToolUse" | "PermissionRequest"; +type CodexHookEventName = "PreToolUse" | "PostToolUse" | "PermissionRequest" | "Stop"; const CODEX_HOOK_EVENT_BY_NATIVE_EVENT: Record = { pre_tool_use: "PreToolUse", post_tool_use: "PostToolUse", permission_request: "PermissionRequest", + before_agent_finalize: "Stop", }; export function buildCodexNativeHookRelayConfig(params: { @@ -53,6 +55,7 @@ export function buildCodexNativeHookRelayDisabledConfig(): JsonObject { "hooks.PreToolUse": [], "hooks.PostToolUse": [], "hooks.PermissionRequest": [], + "hooks.Stop": [], }; } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 29492f4031a..cf78c01c3d7 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -570,6 +570,7 @@ describe("runCodexAppServerAttempt", () => { "hooks.PreToolUse": [], "hooks.PostToolUse": [], "hooks.PermissionRequest": [], + "hooks.Stop": [], }, }), ); diff --git a/src/agents/harness/hook-context.ts b/src/agents/harness/hook-context.ts index 6eb6538b590..c5b519e1b54 100644 --- a/src/agents/harness/hook-context.ts +++ b/src/agents/harness/hook-context.ts @@ -6,6 +6,8 @@ export type AgentHarnessHookContext = { sessionKey?: string; sessionId?: string; workspaceDir?: string; + modelProviderId?: string; + modelId?: string; messageProvider?: string; trigger?: string; channelId?: string; @@ -18,6 +20,8 @@ export function buildAgentHookContext(params: AgentHarnessHookContext): PluginHo ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), ...(params.sessionId ? { sessionId: params.sessionId } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.modelProviderId ? { modelProviderId: params.modelProviderId } : {}), + ...(params.modelId ? { modelId: params.modelId } : {}), ...(params.messageProvider ? { messageProvider: params.messageProvider } : {}), ...(params.trigger ? { trigger: params.trigger } : {}), ...(params.channelId ? { channelId: params.channelId } : {}), diff --git a/src/agents/harness/lifecycle-hook-helpers.ts b/src/agents/harness/lifecycle-hook-helpers.ts index fd45d7b6114..15802293d2f 100644 --- a/src/agents/harness/lifecycle-hook-helpers.ts +++ b/src/agents/harness/lifecycle-hook-helpers.ts @@ -2,6 +2,8 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookAgentEndEvent, + PluginHookBeforeAgentFinalizeEvent, + PluginHookBeforeAgentFinalizeResult, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, } from "../../plugins/hook-types.js"; @@ -52,3 +54,42 @@ export function runAgentHarnessAgentEndHook(params: { log.warn(`agent_end hook failed: ${String(error)}`); }); } + +export type AgentHarnessBeforeAgentFinalizeOutcome = + | { action: "continue" } + | { action: "revise"; reason: string } + | { action: "finalize"; reason?: string }; + +export async function runAgentHarnessBeforeAgentFinalizeHook(params: { + event: PluginHookBeforeAgentFinalizeEvent; + ctx: AgentHarnessHookContext; + hookRunner?: AgentHarnessHookRunner; +}): Promise { + const hookRunner = params.hookRunner ?? getGlobalHookRunner(); + if (!hookRunner?.hasHooks("before_agent_finalize")) { + return { action: "continue" }; + } + try { + return normalizeBeforeAgentFinalizeResult( + await hookRunner.runBeforeAgentFinalize(params.event, buildAgentHookContext(params.ctx)), + ); + } catch (error) { + log.warn(`before_agent_finalize hook failed: ${String(error)}`); + return { action: "continue" }; + } +} + +function normalizeBeforeAgentFinalizeResult( + result: PluginHookBeforeAgentFinalizeResult | undefined, +): AgentHarnessBeforeAgentFinalizeOutcome { + if (result?.action === "finalize") { + return result.reason?.trim() + ? { action: "finalize", reason: result.reason.trim() } + : { action: "finalize" }; + } + if (result?.action === "revise") { + const reason = result.reason?.trim(); + return reason ? { action: "revise", reason } : { action: "continue" }; + } + return { action: "continue" }; +} diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index aeb6445225f..b3e50786d4c 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -355,7 +355,7 @@ describe("native hook relay registry", () => { runId: "run-1", }); - for (const event of ["pre_tool_use", "post_tool_use"] as const) { + for (const event of ["pre_tool_use", "post_tool_use", "before_agent_finalize"] as const) { await expect( invokeNativeHookRelay({ provider: "codex", @@ -709,6 +709,108 @@ describe("native hook relay registry", () => { ); }); + it("maps Codex Stop to before_agent_finalize revision output", async () => { + const beforeAgentFinalize = vi.fn(async () => ({ + action: "revise", + reason: "please run the focused tests before finalizing", + })); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_agent_finalize", handler: beforeAgentFinalize }, + ]), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "before_agent_finalize", + rawPayload: { + hook_event_name: "Stop", + session_id: "codex-session-1", + turn_id: "turn-1", + cwd: "/repo", + transcript_path: "/tmp/session.jsonl", + model: "gpt-5.4", + permission_mode: "workspace-write", + stop_hook_active: true, + last_assistant_message: "done", + }, + }); + + expect(response).toEqual({ + stdout: `${JSON.stringify({ + decision: "block", + reason: "please run the focused tests before finalizing", + })}\n`, + stderr: "", + exitCode: 0, + }); + expect(beforeAgentFinalize).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + turnId: "turn-1", + provider: "codex", + model: "gpt-5.4", + cwd: "/repo", + transcriptPath: "/tmp/session.jsonl", + stopHookActive: true, + lastAssistantMessage: "done", + }), + expect.objectContaining({ + agentId: "agent-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + workspaceDir: "/repo", + modelId: "gpt-5.4", + }), + ); + }); + + it("maps before_agent_finalize finalize output to Codex continue false", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_agent_finalize", + handler: vi.fn(async () => ({ action: "finalize", reason: "already checked" })), + }, + ]), + ); + const relay = registerNativeHookRelay({ + provider: "codex", + sessionId: "session-1", + runId: "run-1", + }); + + const response = await invokeNativeHookRelay({ + provider: "codex", + relayId: relay.relayId, + event: "before_agent_finalize", + rawPayload: { + hook_event_name: "Stop", + stop_hook_active: false, + }, + }); + + expect(response).toEqual({ + stdout: `${JSON.stringify({ + continue: false, + stopReason: "already checked", + })}\n`, + stderr: "", + exitCode: 0, + }); + }); + it("maps PermissionRequest approval allow and deny decisions to Codex hook output", async () => { const relay = registerNativeHookRelay({ provider: "codex", diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index fb15169c37f..d1b201fd817 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -7,6 +7,7 @@ import { runBeforeToolCallHook } from "../pi-tools.before-tool-call.js"; import { normalizeToolName } from "../tool-policy.js"; import { callGatewayTool } from "../tools/gateway.js"; import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js"; +import { runAgentHarnessBeforeAgentFinalizeHook } from "./lifecycle-hook-helpers.js"; export type JsonValue = | null @@ -20,6 +21,7 @@ export const NATIVE_HOOK_RELAY_EVENTS = [ "pre_tool_use", "post_tool_use", "permission_request", + "before_agent_finalize", ] as const; export const NATIVE_HOOK_RELAY_PROVIDERS = ["codex"] as const; @@ -38,6 +40,11 @@ export type NativeHookRelayInvocation = { runId: string; cwd?: string; model?: string; + turnId?: string; + transcriptPath?: string; + permissionMode?: string; + stopHookActive?: boolean; + lastAssistantMessage?: string; toolName?: string; toolUseId?: string; rawPayload: JsonValue; @@ -93,7 +100,19 @@ export type InvokeNativeHookRelayParams = { }; type NativeHookRelayInvocationMetadata = Partial< - Pick + Pick< + NativeHookRelayInvocation, + | "nativeEventName" + | "cwd" + | "model" + | "turnId" + | "transcriptPath" + | "permissionMode" + | "stopHookActive" + | "lastAssistantMessage" + | "toolName" + | "toolUseId" + > >; type NativeHookRelayProviderAdapter = { @@ -102,6 +121,8 @@ type NativeHookRelayProviderAdapter = { readToolResponse: (rawPayload: JsonValue) => unknown; renderNoopResponse: (event: NativeHookRelayEvent) => NativeHookRelayProcessResponse; renderPreToolUseBlockResponse: (reason: string) => NativeHookRelayProcessResponse; + renderBeforeAgentFinalizeReviseResponse: (reason: string) => NativeHookRelayProcessResponse; + renderBeforeAgentFinalizeStopResponse: (reason?: string) => NativeHookRelayProcessResponse; renderPermissionDecisionResponse: ( decision: NativeHookRelayPermissionDecision, message?: string, @@ -185,6 +206,22 @@ const nativeHookRelayProviderAdapters: Record< stderr: "", exitCode: 0, }), + renderBeforeAgentFinalizeReviseResponse: (reason) => ({ + stdout: `${JSON.stringify({ + decision: "block", + reason, + })}\n`, + stderr: "", + exitCode: 0, + }), + renderBeforeAgentFinalizeStopResponse: (reason) => ({ + stdout: `${JSON.stringify({ + continue: false, + ...(reason?.trim() ? { stopReason: reason.trim() } : {}), + })}\n`, + stderr: "", + exitCode: 0, + }), renderPermissionDecisionResponse: (decision, message) => ({ stdout: `${JSON.stringify({ hookSpecificOutput: { @@ -367,6 +404,9 @@ async function processNativeHookRelayInvocation(params: { if (params.invocation.event === "post_tool_use") { return runNativeHookRelayPostToolUse(params); } + if (params.invocation.event === "before_agent_finalize") { + return runNativeHookRelayBeforeAgentFinalize(params); + } return runNativeHookRelayPermissionRequest(params); } @@ -464,6 +504,46 @@ async function runNativeHookRelayPermissionRequest(params: { return params.adapter.renderNoopResponse(params.invocation.event); } +async function runNativeHookRelayBeforeAgentFinalize(params: { + registration: NativeHookRelayRegistration; + invocation: NativeHookRelayInvocation; + adapter: NativeHookRelayProviderAdapter; +}): Promise { + const outcome = await runAgentHarnessBeforeAgentFinalizeHook({ + event: { + runId: params.registration.runId, + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + ...(params.invocation.turnId ? { turnId: params.invocation.turnId } : {}), + provider: params.registration.provider, + ...(params.invocation.model ? { model: params.invocation.model } : {}), + ...(params.invocation.cwd ? { cwd: params.invocation.cwd } : {}), + ...(params.invocation.transcriptPath + ? { transcriptPath: params.invocation.transcriptPath } + : {}), + stopHookActive: params.invocation.stopHookActive === true, + ...(params.invocation.lastAssistantMessage + ? { lastAssistantMessage: params.invocation.lastAssistantMessage } + : {}), + }, + ctx: { + ...(params.registration.agentId ? { agentId: params.registration.agentId } : {}), + sessionId: params.registration.sessionId, + ...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}), + runId: params.registration.runId, + ...(params.invocation.cwd ? { workspaceDir: params.invocation.cwd } : {}), + ...(params.invocation.model ? { modelId: params.invocation.model } : {}), + }, + }); + if (outcome.action === "revise") { + return params.adapter.renderBeforeAgentFinalizeReviseResponse(outcome.reason); + } + if (outcome.action === "finalize") { + return params.adapter.renderBeforeAgentFinalizeStopResponse(outcome.reason); + } + return params.adapter.renderNoopResponse(params.invocation.event); +} + async function startNativeHookRelayPermissionApprovalWithBudget(params: { registration: NativeHookRelayRegistration; approvalKey: string; @@ -720,6 +800,26 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc if (model) { metadata.model = model; } + const turnId = readOptionalString(payload.turn_id); + if (turnId) { + metadata.turnId = turnId; + } + const transcriptPath = readOptionalString(payload.transcript_path); + if (transcriptPath) { + metadata.transcriptPath = transcriptPath; + } + const permissionMode = readOptionalString(payload.permission_mode); + if (permissionMode) { + metadata.permissionMode = permissionMode; + } + const stopHookActive = readOptionalBoolean(payload.stop_hook_active); + if (stopHookActive !== undefined) { + metadata.stopHookActive = stopHookActive; + } + const lastAssistantMessage = readOptionalString(payload.last_assistant_message); + if (lastAssistantMessage) { + metadata.lastAssistantMessage = lastAssistantMessage; + } const toolName = readOptionalString(payload.tool_name); if (toolName) { metadata.toolName = toolName; @@ -950,7 +1050,12 @@ function readNativeHookRelayProvider(value: unknown): NativeHookRelayProvider { } function readNativeHookRelayEvent(value: unknown): NativeHookRelayEvent { - if (value === "pre_tool_use" || value === "post_tool_use" || value === "permission_request") { + if ( + value === "pre_tool_use" || + value === "post_tool_use" || + value === "permission_request" || + value === "before_agent_finalize" + ) { return value; } throw new Error("unsupported native hook relay event"); @@ -967,6 +1072,10 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function isJsonValue(value: unknown): value is JsonValue { const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; let nodes = 0; diff --git a/src/cli/native-hook-relay-cli.test.ts b/src/cli/native-hook-relay-cli.test.ts index ccfe1e20ec7..9cfa6c6f32a 100644 --- a/src/cli/native-hook-relay-cli.test.ts +++ b/src/cli/native-hook-relay-cli.test.ts @@ -186,4 +186,26 @@ describe("native hook relay CLI", () => { expect(stdout.text()).toBe(""); expect(stderr.text()).toContain("native hook relay unavailable"); }); + + it("keeps before_agent_finalize unavailable handling observational", async () => { + const callGateway = vi.fn(async () => { + throw new Error("gateway closed"); + }); + const stdout = createWritableTextBuffer(); + const stderr = createWritableTextBuffer(); + + const exitCode = await runNativeHookRelayCli( + { provider: "codex", relayId: "relay-1", event: "before_agent_finalize" }, + { + stdin: createReadableTextStream("{}"), + stdout, + stderr, + callGateway: callGateway as never, + }, + ); + + expect(exitCode).toBe(0); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("native hook relay unavailable"); + }); }); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 302be1e45d1..f36a22a4f02 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -22974,7 +22974,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "boolean", title: "Allow Conversation Access Hooks", description: - "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.", }, }, additionalProperties: false, @@ -27491,7 +27491,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "plugins.entries.*.hooks.allowConversationAccess": { label: "Allow Conversation Access Hooks", - help: "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + help: "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.", tags: ["access"], }, "plugins.entries.*.hooks.allowPromptInjection": { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 255efa8fc12..b70235f69c1 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -768,6 +768,7 @@ describe("config help copy quality", () => { const pluginConversationPolicy = FIELD_HELP["plugins.entries.*.hooks.allowConversationAccess"]; expect(pluginConversationPolicy.includes("llm_input")).toBe(true); expect(pluginConversationPolicy.includes("llm_output")).toBe(true); + expect(pluginConversationPolicy.includes("before_agent_finalize")).toBe(true); expect(pluginConversationPolicy.includes("agent_end")).toBe(true); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d44e74dbe80..9220912cecd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1157,7 +1157,7 @@ export const FIELD_HELP: Record = { "plugins.entries.*.hooks.allowPromptInjection": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "plugins.entries.*.hooks.allowConversationAccess": - "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, `before_agent_finalize`, and `agent_end`. Non-bundled plugins must opt in explicitly.", "plugins.entries.*.subagent": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", "plugins.entries.*.subagent.allowModelOverride": diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index d0cc9f1fad1..c6e67147517 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -116,6 +116,7 @@ export { runAgentHarnessBeforeMessageWriteHook, } from "../agents/harness/hook-helpers.js"; export { + runAgentHarnessBeforeAgentFinalizeHook, runAgentHarnessAgentEndHook, runAgentHarnessLlmInputHook, runAgentHarnessLlmOutputHook, diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index d590f5f3063..d96e7f6266e 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -63,6 +63,7 @@ export type PluginHookName = | "model_call_ended" | "llm_input" | "llm_output" + | "before_agent_finalize" | "agent_end" | "before_compaction" | "after_compaction" @@ -96,6 +97,7 @@ export const PLUGIN_HOOK_NAMES = [ "model_call_ended", "llm_input", "llm_output", + "before_agent_finalize", "agent_end", "before_compaction", "after_compaction", @@ -146,6 +148,7 @@ export const isPromptInjectionHookName = (hookName: PluginHookName): boolean => export const CONVERSATION_HOOK_NAMES = [ "llm_input", "llm_output", + "before_agent_finalize", "agent_end", ] as const satisfies readonly PluginHookName[]; @@ -248,6 +251,30 @@ export type PluginHookAgentEndEvent = { durationMs?: number; }; +export type PluginHookBeforeAgentFinalizeEvent = { + runId?: string; + sessionId: string; + sessionKey?: string; + turnId?: string; + provider?: string; + model?: string; + cwd?: string; + transcriptPath?: string; + stopHookActive: boolean; + lastAssistantMessage?: string; + messages?: unknown[]; +}; + +export type PluginHookBeforeAgentFinalizeResult = { + /** + * continue: accept normal finalization. + * revise: block finalization and ask the harness for another model pass. + * finalize: force finalization even if another hook requested revision. + */ + action?: "continue" | "revise" | "finalize"; + reason?: string; +}; + export type PluginHookBeforeCompactionEvent = { messageCount: number; compactingCount?: number; @@ -713,6 +740,13 @@ export type PluginHookHandlerMap = { event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext, ) => Promise | void; + before_agent_finalize: ( + event: PluginHookBeforeAgentFinalizeEvent, + ctx: PluginHookAgentContext, + ) => + | Promise + | PluginHookBeforeAgentFinalizeResult + | void; agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise | void; before_compaction: ( event: PluginHookBeforeCompactionEvent, diff --git a/src/plugins/hooks.before-agent-finalize.test.ts b/src/plugins/hooks.before-agent-finalize.test.ts new file mode 100644 index 00000000000..3db857b739e --- /dev/null +++ b/src/plugins/hooks.before-agent-finalize.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { createMockPluginRegistry, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js"; + +const EVENT = { + runId: "run-1", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + turnId: "turn-1", + provider: "codex", + model: "gpt-5.4", + cwd: "/repo", + transcriptPath: "/tmp/session.jsonl", + stopHookActive: false, + lastAssistantMessage: "done", +}; + +describe("before_agent_finalize hook runner", () => { + it("returns undefined when no hooks are registered", async () => { + const runner = createHookRunner(createMockPluginRegistry([])); + + await expect( + runner.runBeforeAgentFinalize(EVENT, TEST_PLUGIN_AGENT_CTX), + ).resolves.toBeUndefined(); + }); + + it("returns a revise decision with the hook reason", async () => { + const handler = vi.fn().mockResolvedValue({ + action: "revise", + reason: "run the focused tests before finalizing", + }); + const runner = createHookRunner( + createMockPluginRegistry([{ hookName: "before_agent_finalize", handler }]), + ); + + await expect(runner.runBeforeAgentFinalize(EVENT, TEST_PLUGIN_AGENT_CTX)).resolves.toEqual({ + action: "revise", + reason: "run the focused tests before finalizing", + }); + expect(handler).toHaveBeenCalledWith(EVENT, TEST_PLUGIN_AGENT_CTX); + }); + + it("joins multiple revise reasons so the harness can request one follow-up pass", async () => { + const runner = createHookRunner( + createMockPluginRegistry([ + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ action: "revise", reason: "fix lint" }), + }, + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ action: "revise", reason: "then rerun tests" }), + }, + ]), + ); + + await expect(runner.runBeforeAgentFinalize(EVENT, TEST_PLUGIN_AGENT_CTX)).resolves.toEqual({ + action: "revise", + reason: "fix lint\n\nthen rerun tests", + }); + }); + + it("lets finalize override earlier revise decisions", async () => { + const runner = createHookRunner( + createMockPluginRegistry([ + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ action: "revise", reason: "keep going" }), + }, + { + hookName: "before_agent_finalize", + handler: vi.fn().mockResolvedValue({ action: "finalize", reason: "enough" }), + }, + ]), + ); + + await expect(runner.runBeforeAgentFinalize(EVENT, TEST_PLUGIN_AGENT_CTX)).resolves.toEqual({ + action: "finalize", + reason: "enough", + }); + }); + + it("hasHooks reports correctly", () => { + const runner = createHookRunner( + createMockPluginRegistry([{ hookName: "before_agent_finalize", handler: vi.fn() }]), + ); + + expect(runner.hasHooks("before_agent_finalize")).toBe(true); + expect(runner.hasHooks("agent_end")).toBe(false); + }); +}); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 30ff84a0600..b4350eb2d8f 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -14,6 +14,8 @@ import type { PluginHookAfterToolCallEvent, PluginHookAgentContext, PluginHookAgentEndEvent, + PluginHookBeforeAgentFinalizeEvent, + PluginHookBeforeAgentFinalizeResult, PluginHookBeforeAgentReplyEvent, PluginHookBeforeAgentReplyResult, PluginHookBeforeAgentStartEvent, @@ -91,6 +93,8 @@ export type { PluginHookModelCallStartedEvent, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, + PluginHookBeforeAgentFinalizeEvent, + PluginHookBeforeAgentFinalizeResult, PluginHookAgentEndEvent, PluginHookBeforeCompactionEvent, PluginHookBeforeResetEvent, @@ -253,6 +257,34 @@ export function createHookRunner( }), }); + const mergeBeforeAgentFinalize = ( + acc: PluginHookBeforeAgentFinalizeResult | undefined, + next: PluginHookBeforeAgentFinalizeResult, + ): PluginHookBeforeAgentFinalizeResult => { + if (acc?.action === "finalize") { + return acc; + } + if (next.action === "finalize") { + return { action: "finalize", reason: next.reason }; + } + if (acc?.action === "revise" && next.action === "revise") { + return { + action: "revise", + reason: concatOptionalTextSegments({ + left: acc.reason, + right: next.reason, + }), + }; + } + if (acc?.action === "revise") { + return acc; + } + if (next.action === "revise") { + return { action: "revise", reason: next.reason }; + } + return next.action === "continue" ? { action: "continue", reason: next.reason } : (acc ?? next); + }; + const mergeSubagentSpawningResult = ( acc: PluginHookSubagentSpawningResult | undefined, next: PluginHookSubagentSpawningResult, @@ -646,6 +678,23 @@ export function createHookRunner( return runVoidHook("llm_output", event, ctx); } + /** + * Run before_agent_finalize hook. + * Allows plugins to request one more model pass before a natural final reply + * is accepted. This is not the user-facing /stop cancellation path. + */ + async function runBeforeAgentFinalize( + event: PluginHookBeforeAgentFinalizeEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runModifyingHook<"before_agent_finalize", PluginHookBeforeAgentFinalizeResult>( + "before_agent_finalize", + withAgentRunId(event, ctx), + ctx, + { mergeResults: mergeBeforeAgentFinalize }, + ); + } + /** * Run before_compaction hook. */ @@ -1156,6 +1205,7 @@ export function createHookRunner( runModelCallEnded, runLlmInput, runLlmOutput, + runBeforeAgentFinalize, runAgentEnd, runBeforeCompaction, runAfterCompaction, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a48d67c0ef9..9c4da22cb33 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -5880,6 +5880,7 @@ module.exports = { body: `module.exports = { id: "conversation-hooks", register(api) { api.on("llm_input", () => undefined); api.on("llm_output", () => undefined); + api.on("before_agent_finalize", () => undefined); api.on("agent_end", () => undefined); } };`, }); @@ -5897,7 +5898,7 @@ module.exports = { "non-bundled plugins must set plugins.entries.conversation-hooks.hooks.allowConversationAccess=true", ), ); - expect(blockedDiagnostics).toHaveLength(3); + expect(blockedDiagnostics).toHaveLength(4); }); it("allows conversation typed hooks for non-bundled plugins when explicitly enabled", () => { @@ -5908,6 +5909,7 @@ module.exports = { body: `module.exports = { id: "conversation-hooks-allowed", register(api) { api.on("llm_input", () => undefined); api.on("llm_output", () => undefined); + api.on("before_agent_finalize", () => undefined); api.on("agent_end", () => undefined); } };`, }); @@ -5929,6 +5931,7 @@ module.exports = { expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([ "llm_input", "llm_output", + "before_agent_finalize", "agent_end", ]); });