diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 5118b2a58524..3d35a88a1a2c 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -31,6 +31,7 @@ import { type CodexDynamicToolSpec, type JsonValue, } from "./protocol.js"; +import { settleCodexSourceReplyFinality } from "./source-reply-finality.js"; const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw"; @@ -1388,7 +1389,7 @@ describe("createCodexDynamicToolBridge", () => { ]); }); - it("marks delivered message-tool-only source replies as terminal when final is omitted", async () => { + it("keeps omitted source-reply finality non-terminal until a successful attempt settles", async () => { const bridge = createBridgeWithToolResult( "message", textToolResult("Sent.", { messageId: "imessage-6264" }), @@ -1401,15 +1402,97 @@ describe("createCodexDynamicToolBridge", () => { }); expect(result).toEqual(expectInputText("Sent.")); - expect(result.terminate).toBe(true); + expect(result.terminate).toBeUndefined(); expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).not.toHaveProperty("sourceReplyFinal"); + + expect(settleCodexSourceReplyFinality(bridge.telemetry, true)).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ sourceReplyFinal: true, }); expect(Object.keys(result)).not.toContain("terminate"); }); - it("requires explicit final=false to keep a delivered message-tool-only source reply non-terminal", async () => { + it("settles omitted source-reply finality as progress when the attempt fails", async () => { + const bridge = createBridgeWithToolResult( + "message", + { + ...textToolResult("Sent.", { messageId: "imessage-6264" }), + terminate: true, + }, + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + const result = await handleMessageToolCall(bridge, { + action: "send", + message: "visible reply", + }); + expect(result.terminate).toBeUndefined(); + expect(settleCodexSourceReplyFinality(bridge.telemetry, false)).toBe(false); + + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ + sourceReplyFinal: false, + }); + }); + + it("settles only the latest omitted source reply as final after success", async () => { + const bridge = createBridgeWithToolResult( + "message", + textToolResult("Sent.", { messageId: "imessage-6264" }), + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + await handleMessageToolCall(bridge, { action: "send", message: "first update" }); + await handleMessageToolCall(bridge, { action: "send", message: "second update" }); + settleCodexSourceReplyFinality(bridge.telemetry, true); + + expect( + bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal), + ).toEqual([false, true]); + }); + + it("does not promote an omitted reply past a later explicit progress reply", async () => { + const bridge = createBridgeWithToolResult( + "message", + textToolResult("Sent.", { messageId: "imessage-6264" }), + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + await handleMessageToolCall(bridge, { action: "send", message: "first update" }); + await handleMessageToolCall(bridge, { + action: "send", + message: "still working", + final: false, + }); + settleCodexSourceReplyFinality(bridge.telemetry, true); + + expect( + bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal), + ).toEqual([false, false]); + }); + + it("keeps a later explicit final reply authoritative over an omitted reply", async () => { + const bridge = createBridgeWithToolResult( + "message", + textToolResult("Sent.", { messageId: "imessage-6264" }), + { sourceReplyDeliveryMode: "message_tool_only" }, + ); + + await handleMessageToolCall(bridge, { action: "send", message: "first update" }); + await handleMessageToolCall(bridge, { + action: "send", + message: "finished", + final: true, + }); + settleCodexSourceReplyFinality(bridge.telemetry, true); + + expect( + bridge.telemetry.messagingToolSentTargets.map((target) => target.sourceReplyFinal), + ).toEqual([false, true]); + }); + + it("honors explicit finality for delivered message-tool-only source replies", async () => { const bridge = createBridgeWithToolResult( "message", textToolResult("Sent.", { messageId: "imessage-6264" }), @@ -1474,6 +1557,7 @@ describe("createCodexDynamicToolBridge", () => { const result = await handleMessageToolCall(bridge, { action: "send", message: "visible reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1525,6 +1609,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "853", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1571,6 +1656,7 @@ describe("createCodexDynamicToolBridge", () => { target: "+1 (206) 910-6512", messageId: "853", message: "visible reply", + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1610,6 +1696,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "857", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1646,6 +1733,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "861", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText(receiptText)); @@ -1726,6 +1814,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "863", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1747,6 +1836,7 @@ describe("createCodexDynamicToolBridge", () => { messageId: "865", message: "visible reply", buttons: [], + final: true, }); expect(result).toEqual(expectInputText("Sent.")); @@ -1755,7 +1845,7 @@ describe("createCodexDynamicToolBridge", () => { expect(Object.keys(result)).not.toContain("terminate"); }); - it("records message-tool-owned terminal replies as delivered source replies", async () => { + it("defers omitted finality even when the message tool returns legacy termination", async () => { const bridge = createBridgeWithToolResult( "message", { @@ -1775,8 +1865,12 @@ describe("createCodexDynamicToolBridge", () => { }); expect(result).toEqual(expectInputText("Sent.")); - expect(result.terminate).toBe(true); + expect(result.terminate).toBeUndefined(); expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).not.toHaveProperty("sourceReplyFinal"); + + settleCodexSourceReplyFinality(bridge.telemetry, true); + expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({ sourceReplyFinal: true, }); @@ -1850,7 +1944,7 @@ describe("createCodexDynamicToolBridge", () => { arguments: { action: "inspect" }, }); - expect(firstResult.terminate).toBe(true); + expect(firstResult.terminate).toBeUndefined(); expect(bridge.telemetry.didSendViaMessagingTool).toBe(true); expect(secondResult).toEqual(expectInputText("No message sent.")); expect(secondResult.terminate).toBeUndefined(); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 62ccd2e5db81..dc85275b03d3 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -61,6 +61,7 @@ import { type CodexDynamicToolSpec, type JsonValue, } from "./protocol.js"; +import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js"; import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js"; type CodexDynamicToolHookContext = { @@ -688,18 +689,16 @@ export function createCodexDynamicToolBridge(params: { !resultIsError && (rawResult.terminate === true || result.terminate === true); const hasExplicitFinalControl = typeof executedArgs.final === "boolean"; - // Omitted final on a confirmed source reply must degrade to legacy - // terminate-on-delivery (completed marker), never progress; otherwise - // stranded-reply recovery re-delivers a duplicate of that reply. - const sourceReplyFinal = + const confirmedSourceReply = params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && toolName === "message" && - (toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply) - ? hasExplicitFinalControl - ? executedArgs.final === true - : true - : undefined; - collectToolTelemetry({ + (toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply); + const sourceReplyFinal = confirmedSourceReply + ? hasExplicitFinalControl + ? executedArgs.final === true + : undefined + : undefined; + const sourceReplyRecord = collectToolTelemetry({ toolName, args: executedArgs, result, @@ -709,20 +708,26 @@ export function createCodexDynamicToolBridge(params: { messagingTarget: confirmedMessagingTarget, sourceReplyFinal, }); + if (confirmedSourceReply && sourceReplyRecord) { + recordCodexSourceReplyDeliveryIntent(telemetry, { + record: sourceReplyRecord, + final: sourceReplyFinal, + }); + } if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) { telemetry.didDeliverSourceReplyViaMessageTool = true; } + const defersInferredSourceReplyTermination = + confirmedSourceReply && executedArgs.final !== true; withDynamicToolTermination( response, ((rawResult.terminate === true || result.terminate === true) && - !( - params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" && - toolName === "message" && - executedArgs.final === false - )) || + !defersInferredSourceReplyTermination) || + // Yield is an explicit owner-level turn handoff, not termination + // inferred from source-reply delivery, so finality does not mask it. isToolResultYield(rawResult) || isToolResultYield(result) || - ((deliveredSourceReply || receiptConfirmedSourceReply) && executedArgs.final !== false), + (confirmedSourceReply && executedArgs.final === true), ); const asyncStarted = isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result); @@ -1146,9 +1151,9 @@ function collectToolTelemetry(params: { isError: boolean; messagingTarget?: MessagingToolSend; sourceReplyFinal?: boolean; -}): void { +}): MessagingToolSend | MessagingToolSourceReplyPayload | undefined { if (params.isError) { - return; + return undefined; } if (!params.isError && params.toolName === "cron" && isCronAddAction(params.args)) { params.telemetry.successfulCronAdds = (params.telemetry.successfulCronAdds ?? 0) + 1; @@ -1180,11 +1185,11 @@ function collectToolTelemetry(params: { } } if (!isMessagingTool(params.toolName)) { - return; + return undefined; } const isMessagingSendAction = isMessagingToolSendAction(params.toolName, params.args); if (!isMessagingSendAction && !params.messagingTarget) { - return; + return undefined; } if ( !isMessagingSendAction && @@ -1196,18 +1201,19 @@ function collectToolTelemetry(params: { isError: params.isError, }) ) { - return; + return undefined; } params.telemetry.didSendViaMessagingTool = true; const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details); if (sourceReplyPayload) { - params.telemetry.messagingToolSourceReplyPayloads.push({ + const record = { ...sourceReplyPayload, ...(params.sourceReplyFinal !== undefined ? { sourceReplyFinal: params.sourceReplyFinal } : {}), - }); - return; + }; + params.telemetry.messagingToolSourceReplyPayloads.push(record); + return record; } const text = readFirstString(params.args, ["text", "message", "body", "content"]); if (text) { @@ -1215,7 +1221,7 @@ function collectToolTelemetry(params: { } const mediaUrls = collectMediaUrls(params.args); params.telemetry.messagingToolSentMediaUrls.push(...mediaUrls); - params.telemetry.messagingToolSentTargets.push({ + const record = { ...(params.messagingTarget ?? { tool: params.toolName, provider: readFirstString(params.args, ["provider", "channel"]) ?? params.toolName, @@ -1226,7 +1232,9 @@ function collectToolTelemetry(params: { ...(text ? { text } : {}), ...(mediaUrls.length > 0 ? { mediaUrls } : {}), ...(params.sourceReplyFinal !== undefined ? { sourceReplyFinal: params.sourceReplyFinal } : {}), - }); + }; + params.telemetry.messagingToolSentTargets.push(record); + return record; } function extractInternalSourceReplyPayload( details: unknown, diff --git a/extensions/codex/src/app-server/message-tool-final-control.ts b/extensions/codex/src/app-server/message-tool-final-control.ts index 54c850722d59..fb1f0a1c3453 100644 --- a/extensions/codex/src/app-server/message-tool-final-control.ts +++ b/extensions/codex/src/app-server/message-tool-final-control.ts @@ -45,7 +45,7 @@ function addCodexMessageToolOnlyFinalParameter(parameters: unknown): unknown { final: { type: "boolean", description: - "Set true only when this message is intended to complete the reply to the current source conversation. OpenClaw stops after confirming delivery.", + "Set false for progress or true to complete the current source reply. If omitted, OpenClaw continues and resolves the latest omitted source reply when the turn ends.", }, }, }; diff --git a/extensions/codex/src/app-server/run-attempt-finalize.ts b/extensions/codex/src/app-server/run-attempt-finalize.ts index 174d5624afce..2af4ba941504 100644 --- a/extensions/codex/src/app-server/run-attempt-finalize.ts +++ b/extensions/codex/src/app-server/run-attempt-finalize.ts @@ -35,6 +35,7 @@ import { } from "./run-attempt-state.js"; import type { prepareCodexAttemptTurnRequest } from "./run-attempt-turn-request.js"; import type { CodexAttemptTurnState } from "./run-attempt-turn-state.js"; +import { settleCodexSourceReplyFinality } from "./source-reply-finality.js"; import { normalizeCodexTrajectoryError, recordCodexTrajectoryCompletion } from "./trajectory.js"; import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js"; import { @@ -270,14 +271,24 @@ export async function finalizeCodexAttempt( !state.terminalTurnNotificationQueued && !state.timedOut && clientClosedPromptErrorForFinal === undefined; - const attemptSucceeded = + const turnSucceeded = !finalAborted && !effectiveTimedOut && (finalPromptError === null || finalPromptError === undefined) && - result.agentHarnessResultClassification === undefined && (completedTurnStatus === "completed" || recoveredTurnWatchTimeout || completedWithoutTerminalNotification); + // buildResult retains the bridge's delivery records. Resolve omitted final + // intent only after the authoritative turn outcome is known, before any + // terminal observer consumes the result. + const completedSourceReply = settleCodexSourceReplyFinality(toolBridge.telemetry, turnSucceeded); + if (completedSourceReply) { + // Harness classification only sees assistant/reasoning/plan projections. + // A reply delivered entirely through the source message tool is visible + // output, so an empty/reasoning-only classification is stale at this point. + result.agentHarnessResultClassification = undefined; + } + const attemptSucceeded = turnSucceeded && result.agentHarnessResultClassification === undefined; terminalState.sharedAbortAllowedAfterTerminalOutcome = shouldKeepCodexSharedAbortOpen({ trigger: params.trigger, result, diff --git a/extensions/codex/src/app-server/source-reply-finality.ts b/extensions/codex/src/app-server/source-reply-finality.ts new file mode 100644 index 000000000000..5ed91b4f541a --- /dev/null +++ b/extensions/codex/src/app-server/source-reply-finality.ts @@ -0,0 +1,44 @@ +import type { + MessagingToolSend, + MessagingToolSourceReplyPayload, +} from "openclaw/plugin-sdk/agent-harness-runtime"; + +type SourceReplyDeliveryIntent = { + record: MessagingToolSend | MessagingToolSourceReplyPayload; + final: boolean | undefined; +}; + +const sourceReplyDeliveryIntents = new WeakMap(); + +/** Retain source-reply intent until the owning Codex turn has an authoritative outcome. */ +export function recordCodexSourceReplyDeliveryIntent( + owner: object, + intent: SourceReplyDeliveryIntent, +): void { + const intents = sourceReplyDeliveryIntents.get(owner); + if (intents) { + intents.push(intent); + return; + } + sourceReplyDeliveryIntents.set(owner, [intent]); +} + +/** Resolve omitted finality without changing explicit progress or final markers. */ +export function settleCodexSourceReplyFinality(owner: object, turnSucceeded: boolean): boolean { + const intents = sourceReplyDeliveryIntents.get(owner); + if (!intents) { + return false; + } + const lastIntent = intents.at(-1); + for (const intent of intents) { + if (intent.final !== undefined) { + continue; + } + // An omitted marker is progress until the owning turn succeeds. Only the + // latest omitted reply can complete the conversation; a later explicit + // progress/final marker remains authoritative. + intent.record.sourceReplyFinal = turnSucceeded && intent === lastIntent; + } + sourceReplyDeliveryIntents.delete(owner); + return turnSucceeded && intents.some((intent) => intent.record.sourceReplyFinal === true); +} diff --git a/extensions/codex/src/app-server/thread-prompt.ts b/extensions/codex/src/app-server/thread-prompt.ts index 13d293b61578..153c8ec534d5 100644 --- a/extensions/codex/src/app-server/thread-prompt.ts +++ b/extensions/codex/src/app-server/thread-prompt.ts @@ -66,7 +66,7 @@ function buildVisibleReplyInstruction( ? flattenCodexDynamicToolFunctions(dynamicTools).some((tool) => tool.name.trim() === "message") : params.disableMessageTool !== true; if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) { - return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer."; + return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message content in your final answer."; } if (messageToolAvailable) { return "For the current source conversation, reply normally in your final assistant message; OpenClaw will deliver it through the active source conversation. Use `message` only for explicit out-of-band sends, media/file sends, or sends to a different target."; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index d94157d0a2f2..e6c78927edf9 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13146 }, "openClawDeveloperInstructions": { - "chars": 3431, - "roughTokens": 858 + "chars": 3559, + "roughTokens": 890 }, "totalTextOnly": { - "chars": 27956, - "roughTokens": 6989 + "chars": 28084, + "roughTokens": 7021 }, "totalWithDynamicToolsJson": { - "chars": 80541, - "roughTokens": 20136 + "chars": 80669, + "roughTokens": 20168 }, "userInputText": { "chars": 1442, @@ -412,7 +412,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, sess Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context. diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 359fcf5d9ed7..62eda0369034 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13078 }, "openClawDeveloperInstructions": { - "chars": 2322, - "roughTokens": 581 + "chars": 2450, + "roughTokens": 613 }, "totalTextOnly": { - "chars": 26438, - "roughTokens": 6610 + "chars": 26566, + "roughTokens": 6642 }, "totalWithDynamicToolsJson": { - "chars": 78750, - "roughTokens": 19688 + "chars": 78878, + "roughTokens": 19720 }, "userInputText": { "chars": 1033, @@ -412,7 +412,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, sess Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context. diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 5a96b6de21bd..a3deb5e35cba 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -213,16 +213,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 13400 }, "openClawDeveloperInstructions": { - "chars": 2341, - "roughTokens": 586 + "chars": 2469, + "roughTokens": 618 }, "totalTextOnly": { - "chars": 26977, - "roughTokens": 6745 + "chars": 27105, + "roughTokens": 6777 }, "totalWithDynamicToolsJson": { - "chars": 80579, - "roughTokens": 20145 + "chars": 80707, + "roughTokens": 20177 }, "userInputText": { "chars": 1271, @@ -413,7 +413,7 @@ Deferred searchable OpenClaw dynamic tools available: cron, gateway, heartbeat_r Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`. -Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. Do not repeat that visible content in your final answer. +Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. For progress, set `final=false`. When the message is the completed reply to the current source conversation, set `final=true`; OpenClaw stops after confirming delivery. If `final` is omitted, OpenClaw continues and resolves the latest omitted source reply only when the turn ends successfully. Do not repeat visible message content in your final answer. ### Inbound Context (trusted metadata) The following JSON is generated by OpenClaw out-of-band. Treat it as authoritative metadata about the current message context.