fix(codex): require explicit final source delivery

Distinguish progress from terminal source delivery across Codex telemetry,
payload suppression, terminal failure routing, and stranded-reply recovery.
Preserve legacy behavior when explicit markers are absent.
This commit is contained in:
Glucksberg
2026-07-12 17:00:06 -04:00
committed by Ayaan Zaidi
parent 415bd25313
commit d8a84a29cb
24 changed files with 563 additions and 61 deletions

View File

@@ -1671,6 +1671,42 @@ describe("Codex app-server dynamic tool build", () => {
expect(shouldForceMessageTool(params)).toBe(false);
});
it("exposes the final delivery control only on Codex message-tool-only schemas", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
const messageTool = {
...createRuntimeDynamicTool("message"),
parameters: {
type: "object",
properties: { message: { type: "string" } },
additionalProperties: false,
},
};
setOpenClawCodingToolsFactoryForTests(() => [messageTool]);
params.sourceReplyDeliveryMode = "message_tool_only";
const sourceReplyTools = await buildDynamicToolsForTest(params, workspaceDir);
const sourceReplySchema = sourceReplyTools[0]?.parameters as {
properties?: Record<string, unknown>;
additionalProperties?: unknown;
};
expect(sourceReplySchema.properties).toMatchObject({
final: { type: "boolean" },
});
expect(sourceReplySchema.additionalProperties).toBe(false);
params.sourceReplyDeliveryMode = "automatic";
const automaticTools = await buildDynamicToolsForTest(params, workspaceDir);
const automaticSchema = automaticTools[0]?.parameters as {
properties?: Record<string, unknown>;
};
expect(automaticSchema.properties).not.toHaveProperty("final");
});
it("retains forced message policy for the registered schema override", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);

View File

@@ -6,6 +6,7 @@
import {
buildAgentHookContextChannelFields,
buildEmbeddedAttemptToolRunContext,
cloneAgentRuntimeToolWithParameters,
embeddedAgentLog,
filterProviderNormalizableTools,
isHostScopedAgentToolActive,
@@ -347,9 +348,13 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
onToolOutcome: params.onToolOutcome,
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
});
const codexScopedTools = addCodexMessageToolOnlyFinalControl(
allTools,
params.sourceReplyDeliveryMode,
);
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
const readableAllToolProjection = filterProviderNormalizableTools(codexScopedTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const webSearchPlan = resolveCodexWebSearchPlan({
config: params.config,
@@ -930,6 +935,51 @@ function hideNodeExecDynamicToolParameters(
};
}
/**
* `final` is a Codex-only control for message-tool-only source delivery. Keep
* it on the projected Codex schema so other agent runtimes never receive an
* API contract they do not implement.
*/
function addCodexMessageToolOnlyFinalControl(
tools: OpenClawDynamicTool[],
sourceReplyDeliveryMode: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"],
): OpenClawDynamicTool[] {
if (sourceReplyDeliveryMode !== "message_tool_only") {
return tools;
}
return tools.map((tool) => {
if (normalizeCodexDynamicToolName(tool.name) !== "message") {
return tool;
}
return cloneAgentRuntimeToolWithParameters(
tool,
addCodexMessageToolOnlyFinalParameter(tool.parameters),
);
});
}
function addCodexMessageToolOnlyFinalParameter(parameters: OpenClawDynamicTool["parameters"]) {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return parameters;
}
const schema = parameters as Record<string, unknown>;
const rawProperties = schema.properties;
if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) {
return parameters;
}
return {
...schema,
properties: {
...rawProperties,
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.",
},
},
};
}
function resolveCodexNativeExecutionPolicyForDynamicTools(
input: DynamicToolBuildParams,
): CodexNativeExecutionPolicy {

View File

@@ -1318,21 +1318,38 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal", async () => {
it("requires final=true before a delivered message-tool-only source reply terminates", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const progressResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
final: false,
});
expect(progressResult).toEqual(expectInputText("Sent."));
expect(progressResult.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: false,
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: true,
});
expect(Object.keys(result)).not.toContain("terminate");
});
@@ -1366,6 +1383,7 @@ describe("createCodexDynamicToolBridge", () => {
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1417,11 +1435,15 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "853",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: true,
});
expect(Object.keys(result)).not.toContain("terminate");
});
@@ -1460,6 +1482,7 @@ describe("createCodexDynamicToolBridge", () => {
target: "+1 (206) 910-6512",
messageId: "853",
message: "visible reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1499,6 +1522,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "857",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1535,6 +1559,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "861",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText(receiptText));
@@ -1615,6 +1640,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "863",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1636,6 +1662,7 @@ describe("createCodexDynamicToolBridge", () => {
messageId: "865",
message: "visible reply",
buttons: [],
final: true,
});
expect(result).toEqual(expectInputText("Sent."));
@@ -1666,9 +1693,39 @@ describe("createCodexDynamicToolBridge", () => {
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: true,
});
expect(Object.keys(result)).not.toContain("terminate");
});
it("lets explicit progress override legacy message-tool-owned termination", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { ok: true }),
terminate: true,
} as AgentToolResult<unknown>,
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "868",
message: "Still working.",
buttons: [],
final: false,
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.messagingToolSentTargets.at(-1)).toMatchObject({
sourceReplyFinal: false,
});
});
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
@@ -1699,6 +1756,7 @@ describe("createCodexDynamicToolBridge", () => {
const firstResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
final: true,
});
const secondResult = await bridge.handleToolCall({
threadId: "thread-1",
@@ -1726,6 +1784,7 @@ describe("createCodexDynamicToolBridge", () => {
action: "send",
target: "channel:other",
message: "cross-channel reply",
final: true,
});
expect(result).toEqual(expectInputText("Sent."));

View File

@@ -629,15 +629,6 @@ export function createCodexDynamicToolBridge(params: {
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
: messagingTarget;
collectToolTelemetry({
toolName,
args: executedArgs,
result,
mediaTrustResult: telemetryRawResult,
telemetry,
isError: resultIsError,
messagingTarget: confirmedMessagingTarget,
});
const terminalType =
resultFailureKind === "blocked" ? "blocked" : resultIsError ? "error" : "completed";
const contentItems = convertToolContents(result.content, toolResultMaxChars);
@@ -698,17 +689,41 @@ export function createCodexDynamicToolBridge(params: {
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
const explicitFinalSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
executedArgs.final === true;
const hasExplicitFinalControl = typeof executedArgs.final === "boolean";
const sourceReplyFinal =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
(toolConfirmedSourceReply || deliveredSourceReply || receiptConfirmedSourceReply)
? explicitFinalSourceReply || (toolConfirmedSourceReply && !hasExplicitFinalControl)
: undefined;
collectToolTelemetry({
toolName,
args: executedArgs,
result,
mediaTrustResult: telemetryRawResult,
telemetry,
isError: resultIsError,
messagingTarget: confirmedMessagingTarget,
sourceReplyFinal,
});
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
withDynamicToolTermination(
response,
rawResult.terminate === true ||
result.terminate === true ||
((rawResult.terminate === true || result.terminate === true) &&
!(
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
executedArgs.final === false
)) ||
isToolResultYield(rawResult) ||
isToolResultYield(result) ||
deliveredSourceReply ||
receiptConfirmedSourceReply,
(explicitFinalSourceReply && (deliveredSourceReply || receiptConfirmedSourceReply)),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -1153,6 +1168,7 @@ function collectToolTelemetry(params: {
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
messagingTarget?: MessagingToolSend;
sourceReplyFinal?: boolean;
}): void {
if (params.isError) {
return;
@@ -1208,7 +1224,12 @@ function collectToolTelemetry(params: {
params.telemetry.didSendViaMessagingTool = true;
const sourceReplyPayload = extractInternalSourceReplyPayload(params.result?.details);
if (sourceReplyPayload) {
params.telemetry.messagingToolSourceReplyPayloads.push(sourceReplyPayload);
params.telemetry.messagingToolSourceReplyPayloads.push({
...sourceReplyPayload,
...(params.sourceReplyFinal !== undefined
? { sourceReplyFinal: params.sourceReplyFinal }
: {}),
});
return;
}
const text = readFirstString(params.args, ["text", "message", "body", "content"]);
@@ -1227,6 +1248,7 @@ function collectToolTelemetry(params: {
}),
...(text ? { text } : {}),
...(mediaUrls.length > 0 ? { mediaUrls } : {}),
...(params.sourceReplyFinal !== undefined ? { sourceReplyFinal: params.sourceReplyFinal } : {}),
});
}

View File

@@ -532,6 +532,25 @@ describe("Codex app-server native code mode config", () => {
expect(instructions).not.toContain("Deferred searchable OpenClaw dynamic tools available");
});
it("instructs Codex to mark only completed message-tool-only source replies final", () => {
const params = createAttemptParams({ provider: "openai" });
params.sourceReplyDeliveryMode = "message_tool_only";
const instructions = buildDeveloperInstructions(params, {
dynamicTools: [
{
type: "function",
name: "message",
description: "Send a message",
inputSchema: { type: "object" },
},
],
});
expect(instructions).toContain("For progress, set `final=false`.");
expect(instructions).toContain("set `final=true`");
});
it("keeps durable dynamic tool fingerprints scoped to loading mode", () => {
const inputSchema = {
type: "object",

View File

@@ -3101,7 +3101,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. 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. Do not repeat that visible 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.";

View File

@@ -14,6 +14,8 @@ export type MessagingToolSend = {
text?: string;
mediaUrls?: string[];
hasRichContent?: true;
/** Present only when Codex classified this current-source delivery intent. */
sourceReplyFinal?: boolean;
};
export type MessagingToolSourceReplyPayload = Pick<
@@ -27,4 +29,6 @@ export type MessagingToolSourceReplyPayload = Pick<
| "text"
> & {
idempotencyKey?: string;
/** Present only when Codex classified this current-source delivery intent. */
sourceReplyFinal?: boolean;
};

View File

@@ -1,9 +1,47 @@
import { describe, expect, it } from "vitest";
import {
collectDeliveredMediaUrls,
hasCompletedSourceReplyDeliveryEvidence,
hasVisibleOutboundDeliveryEvidence,
resolveExplicitFinalSourceReplyDeliveryEvidence,
} from "./delivery-evidence.js";
describe("explicit final source-reply delivery evidence", () => {
it("distinguishes progress from a delivered final reply", () => {
expect(
resolveExplicitFinalSourceReplyDeliveryEvidence({
messagingToolSentTargets: [{ sourceReplyFinal: false }],
}),
).toBe(false);
expect(
resolveExplicitFinalSourceReplyDeliveryEvidence({
messagingToolSentTargets: [{ sourceReplyFinal: false }, { sourceReplyFinal: true }],
}),
).toBe(true);
expect(
resolveExplicitFinalSourceReplyDeliveryEvidence({
messagingToolSourceReplyPayloads: [{ sourceReplyFinal: true }],
}),
).toBe(true);
});
it("returns undefined for legacy telemetry without final markers", () => {
expect(
resolveExplicitFinalSourceReplyDeliveryEvidence({
messagingToolSourceReplyPayloads: [{ text: "legacy reply" }],
}),
).toBeUndefined();
});
it("preserves legacy completion evidence when no marker is present", () => {
expect(
hasCompletedSourceReplyDeliveryEvidence({
didDeliverSourceReplyViaMessageTool: true,
}),
).toBe(true);
});
});
describe("visible messaging-tool delivery evidence", () => {
it("keeps the coarse flag when detailed delivery metadata is unavailable", () => {
expect(hasVisibleOutboundDeliveryEvidence({ didSendViaMessagingTool: true })).toBe(true);

View File

@@ -47,6 +47,45 @@ type SourceReplyDeliveryEvidence = {
messagingToolSourceReplyPayloads?: unknown;
};
type ExplicitFinalSourceReplyEvidence = {
messagingToolSentTargets?: unknown;
messagingToolSourceReplyPayloads?: unknown;
};
function collectSourceReplyFinalMarkers(value: unknown): boolean[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const marker = (entry as { sourceReplyFinal?: unknown }).sourceReplyFinal;
return typeof marker === "boolean" ? [marker] : [];
});
}
/** Resolve explicit progress/final evidence, or undefined for legacy runtimes. */
export function resolveExplicitFinalSourceReplyDeliveryEvidence(
result: ExplicitFinalSourceReplyEvidence,
): boolean | undefined {
const markers = [
...collectSourceReplyFinalMarkers(result.messagingToolSentTargets),
...collectSourceReplyFinalMarkers(result.messagingToolSourceReplyPayloads),
];
return markers.length > 0 ? markers.some(Boolean) : undefined;
}
/** Preserve legacy completion semantics unless the runtime emitted progress/final markers. */
export function hasCompletedSourceReplyDeliveryEvidence(
result: SourceReplyDeliveryEvidence & ExplicitFinalSourceReplyEvidence,
): boolean {
return (
resolveExplicitFinalSourceReplyDeliveryEvidence(result) ??
hasCommittedSourceReplyDeliveryEvidence(result)
);
}
function hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

View File

@@ -4344,6 +4344,7 @@ async function runEmbeddedAgentInternal(
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
attempt.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTargets: attempt.messagingToolSentTargets,
messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
agentId: params.agentId,

View File

@@ -180,6 +180,59 @@ describe("buildEmbeddedRunPayloads", () => {
expectNoPayloadTextContaining(payloads, "SECRET_CANARY_69737");
});
it("surfaces a terminal error after only a message-tool progress update", () => {
const payloads = buildPayloads({
lastAssistant: makeAssistant({
stopReason: "error",
errorMessage: "SECRET_PROGRESS_FAILURE",
content: [],
}),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "discord",
to: "channel:C1",
sourceReplyFinal: false,
},
],
sourceReplyDeliveryMode: "message_tool_only",
});
expectSinglePayloadSummary(payloads, {
text: "LLM request failed.",
isError: true,
});
expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({
deliverDespiteSourceReplySuppression: true,
});
expectNoPayloadTextContaining(payloads, "SECRET_PROGRESS_FAILURE");
});
it("keeps terminal errors suppressed after an explicit final message-tool reply", () => {
const payloads = buildPayloads({
lastAssistant: makeAssistant({
stopReason: "error",
errorMessage: "SECRET_POST_FINAL_FAILURE",
content: [],
}),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "discord",
to: "channel:C1",
sourceReplyFinal: true,
},
],
sourceReplyDeliveryMode: "message_tool_only",
});
expect(payloads).toEqual([]);
});
it("suppresses structured provider error messages in user-facing reply payloads", () => {
const rawError =
'{"type":"error","error":{"type":"invalid_request_error","message":"SECRET_CANARY_69737"}}';

View File

@@ -414,6 +414,28 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toEqual([]);
});
it("keeps progress delivery from publishing the private terminal assistant text", () => {
const payloads = buildPayloads({
assistantTexts: ["ordinary final should stay private"],
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "discord",
to: "channel:C1",
sourceReplyFinal: false,
},
],
sourceReplyDeliveryMode: "message_tool_only",
sessionKey: "agent:main",
agentId: "main",
runId: "run-1",
});
expect(payloads).toEqual([]);
});
it("preserves rich-only internal message-tool source replies", () => {
const presentation = {
blocks: [

View File

@@ -42,7 +42,10 @@ import {
isRawApiErrorPayload,
normalizeTextForComparison,
} from "../../embedded-agent-helpers.js";
import type { MessagingToolSourceReplyPayload } from "../../embedded-agent-messaging.types.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "../../embedded-agent-messaging.types.js";
import type { ToolResultFormat } from "../../embedded-agent-subscribe.shared-types.js";
import {
extractAssistantThinking,
@@ -51,6 +54,7 @@ import {
} from "../../embedded-agent-utils.js";
import { isExecLikeToolName, type ToolErrorSummary } from "../../tool-error-summary.js";
import { isLikelyMutatingToolName } from "../../tool-mutation.js";
import { resolveExplicitFinalSourceReplyDeliveryEvidence } from "../delivery-evidence.js";
type ToolMetaEntry = { toolName: string; meta?: string };
type ToolErrorWarningPolicy = {
@@ -581,6 +585,7 @@ export function buildEmbeddedRunPayloads(params: {
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTargets?: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
agentId?: string;
@@ -653,12 +658,21 @@ export function buildEmbeddedRunPayloads(params: {
const deliveredSourceReplyViaMessageTool =
params.sourceReplyDeliveryMode === "message_tool_only" &&
params.didDeliverSourceReplyViaMessageTool === true;
const explicitFinalSourceReply = resolveExplicitFinalSourceReplyDeliveryEvidence({
messagingToolSentTargets: params.messagingToolSentTargets,
messagingToolSourceReplyPayloads: sourceReplyPayloads,
});
const completedSourceReplyViaMessageTool =
explicitFinalSourceReply ?? (hasSourceReplyPayload || deliveredSourceReplyViaMessageTool);
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts =
params.didSendDeterministicApprovalPrompt === true ||
(params.sourceReplyDeliveryMode === "message_tool_only" && hasSourceReplyPayload) ||
deliveredSourceReplyViaMessageTool;
const suppressFailureArtifacts =
params.didSendDeterministicApprovalPrompt === true ||
(params.sourceReplyDeliveryMode === "message_tool_only" && completedSourceReplyViaMessageTool);
const nonEmptyAssistantTexts = params.assistantTexts
.map((text) => sanitizeAssistantVisibleStreamText(text))
.filter((text) => text.trim().length > 0);
@@ -675,7 +689,7 @@ export function buildEmbeddedRunPayloads(params: {
: undefined;
const errorText =
assistantForPayload && lastAssistantNeedsErrorSurface
? suppressAssistantArtifacts
? suppressFailureArtifacts
? undefined
: lastAssistantErrored || rawErrorMessage
? formatUserFacingAssistantErrorText(assistantForPayload, {
@@ -843,7 +857,7 @@ export function buildEmbeddedRunPayloads(params: {
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = hasSourceReplyPayload || deliveredSourceReplyViaMessageTool;
let hasUserFacingAssistantReply = completedSourceReplyViaMessageTool;
const hasUserFacingErrorReply = replyItems.some((item) => item.isError === true);
let hasUserFacingFailureAcknowledgement = false;
for (const text of answerTexts) {
@@ -932,6 +946,13 @@ export function buildEmbeddedRunPayloads(params: {
if (item.isError !== undefined) {
payload.isError = item.isError;
}
if (
item.isError === true &&
params.sourceReplyDeliveryMode === "message_tool_only" &&
explicitFinalSourceReply === false
) {
markReplyPayloadForSourceSuppressionDelivery(payload);
}
if (item.nonTerminalToolErrorWarning) {
setReplyPayloadMetadata(payload, {
nonTerminalToolErrorWarning: true,

View File

@@ -74,6 +74,16 @@ function copyRuntimeToolMetadata(source: AgentTool, target: AgentTool): void {
copyToolTerminalPresentation(source as never, target as never);
}
/** Clone a runtime tool with a projected schema while preserving WeakMap-backed ownership data. */
export function cloneAgentRuntimeToolWithParameters<TTool extends AgentTool>(
source: TTool,
parameters: TTool["parameters"],
): TTool {
const target = { ...source, parameters } as TTool;
copyRuntimeToolMetadata(source, target);
return target;
}
// Duplicate names cannot be matched by map lookup alone, so same-index matches
// take precedence and unique-name fallback covers cloned arrays.
function preserveRuntimeToolMetadata<TSchemaType extends TSchema = TSchema, TResult = unknown>(

View File

@@ -435,6 +435,10 @@ async function executeSendWithResult(params: {
}
describe("message tool gateway timeout", () => {
it("does not advertise the Codex-only final delivery control", () => {
expect(getToolProperties(createMessageTool())).not.toHaveProperty("final");
});
it("advertises timeoutMs as a positive integer", () => {
const tool = createMessageTool();
expect(getToolProperties(tool).timeoutMs).toMatchObject({ type: "integer", minimum: 1 });
@@ -859,7 +863,7 @@ describe("message tool secret scoping", () => {
);
});
it("reuses the unresolved autogenerated idempotency key for exact retries", async () => {
it("keeps the Codex final control out of delivery and retry idempotency", async () => {
mocks.runMessageAction
.mockRejectedValueOnce(new Error("gateway timeout"))
.mockResolvedValueOnce({
@@ -884,6 +888,7 @@ describe("message tool secret scoping", () => {
message: "same",
to: "123",
timeoutMs: 1,
final: true,
}),
).rejects.toThrow("gateway timeout");
const first = firstRunMessageActionInput();
@@ -893,10 +898,13 @@ describe("message tool secret scoping", () => {
timeoutMs: 30_000,
to: "123",
message: "same",
final: false,
});
const second = lastRunMessageActionInput();
expect(first?.params?.idempotencyKey).toBe(second?.params?.idempotencyKey);
expect(first?.params).not.toHaveProperty("final");
expect(second?.params).not.toHaveProperty("final");
});
it("uses delivery params to avoid collisions across distinct sends", async () => {

View File

@@ -1442,6 +1442,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
// Shallow-copy so we don't mutate the original event args (used for logging/dedup).
const params = { ...(args as Record<string, unknown>) };
// `final` is a Codex app-server-only source-delivery control. It must
// not be dispatched to a provider or participate in idempotency.
delete params.final;
// Sanitize outbound text fields in three layers:
//

View File

@@ -3628,6 +3628,40 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
});
it("still recovers a private final after only a message-tool progress delivery", async () => {
await runPrivateFinalCase({
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "whatsapp",
to: "+15550001111",
sourceReplyFinal: false,
},
],
});
expect(warnPrivateFinalSpy).toHaveBeenCalledTimes(1);
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
});
it("does not recover again after an explicit final message-tool delivery", async () => {
await runPrivateFinalCase({
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "whatsapp",
to: "+15550001111",
sourceReplyFinal: true,
},
],
});
expect(warnPrivateFinalSpy).not.toHaveBeenCalled();
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
});
it("still retries when the message tool sent only to a non-source target", async () => {
await runPrivateFinalCase({
messagingToolSentTargets: [{ tool: "message", provider: "whatsapp", to: "+15559998888" }],

View File

@@ -12,9 +12,11 @@ import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { isLikelyContextOverflowError } from "../../agents/embedded-agent-helpers/errors.js";
import {
hasCompletedSourceReplyDeliveryEvidence,
hasCommittedSourceReplyDeliveryEvidence,
hasVisibleCommittedMessagingToolDeliveryEvidence,
hasVisibleOutboundDeliveryEvidence,
resolveExplicitFinalSourceReplyDeliveryEvidence,
} from "../../agents/embedded-agent-runner/delivery-evidence.js";
import { hasDeliberateSilentTerminalReply } from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
import {
@@ -2008,22 +2010,22 @@ export async function runReplyAgent(params: {
});
const committedMessagingToolSourceReplyDelivery =
hasCommittedSourceReplyDeliveryEvidence(runResult);
// #85714: the stranded-retry diagnostic gates on committed source-reply
// evidence. `committedMessagingToolSourceReplyDelivery` is that exact signal
// after the delivery-evidence refactor extracted it into a shared helper.
const committedSourceReplyDelivery = committedMessagingToolSourceReplyDelivery;
const completedSourceReplyDelivery = hasCompletedSourceReplyDeliveryEvidence(runResult);
const hasExplicitSourceReplyCompletion =
resolveExplicitFinalSourceReplyDeliveryEvidence(runResult) !== undefined;
const visibleOutboundDelivery = hasVisibleOutboundDeliveryEvidence(runResult);
const successfulSideEffectDelivery =
successfulSourceReplyDelivery ||
committedMessagingToolSourceReplyDelivery ||
hasVisibleOutboundDeliveryEvidence(runResult) ||
visibleOutboundDelivery ||
runResult.didSendDeterministicApprovalPrompt === true;
const successfulTerminalDelivery =
hasSuccessfulTerminalSourceReplyDelivery({
blockReplyPipeline,
directlySentBlockPayloads,
}) ||
committedMessagingToolSourceReplyDelivery ||
hasVisibleOutboundDeliveryEvidence(runResult) ||
completedSourceReplyDelivery ||
(!hasExplicitSourceReplyCompletion && visibleOutboundDelivery) ||
runResult.didSendDeterministicApprovalPrompt === true;
// Compaction notices are progress, not a terminal reply. Dispatcher-backed
// delivery settles after this run returns, so it cannot prove turn completion here.
@@ -2057,7 +2059,7 @@ export async function runReplyAgent(params: {
if (!sessionKey || !storePath || followupRun.strandedReplyRetry !== true) {
return undefined;
}
if (sessionCtx.InboundEventKind === "room_event" || committedSourceReplyDelivery) {
if (sessionCtx.InboundEventKind === "room_event" || completedSourceReplyDelivery) {
return undefined;
}
const sourceReplyPolicy = resolveSourceReplyPolicy({
@@ -2076,7 +2078,7 @@ export async function runReplyAgent(params: {
}
return buildStrandedReplyDeliveryFailurePayload();
};
if (opts?.sourceReplyDeliveryMode === "message_tool_only" && committedSourceReplyDelivery) {
if (opts?.sourceReplyDeliveryMode === "message_tool_only" && completedSourceReplyDelivery) {
await opts.onObservedReplyDelivery?.();
}
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
@@ -2664,7 +2666,7 @@ export async function runReplyAgent(params: {
shouldWarnAboutPrivateMessageToolFinal({
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
sendPolicyDenied: sourceReplyPolicy.sendPolicyDenied,
successfulSourceReplyDelivery: committedSourceReplyDelivery,
successfulSourceReplyDelivery: completedSourceReplyDelivery,
finalText: assistantFinalText,
});
const retryMissingSourceDelivery =
@@ -2673,7 +2675,7 @@ export async function runReplyAgent(params: {
!isRoomEvent &&
sourceReplyPolicy.sourceReplyDeliveryMode === "message_tool_only" &&
!sourceReplyPolicy.sendPolicyDenied &&
!committedSourceReplyDelivery;
!completedSourceReplyDelivery;
if (isStrandedReply) {
warnPrivateMessageToolFinal({
sessionKey,

View File

@@ -5551,6 +5551,41 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
});
});
it("routes a terminal failure after only a message-tool progress delivery", async () => {
const queued = baseQueuedRun("discord");
await runMessagingCase({
agentResult: {
payloads: [],
meta: { error: { kind: "tool_result_mismatch", message: "private detail" } },
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTargets: [
{
tool: "message",
provider: "discord",
to: "channel:C1",
sourceReplyFinal: false,
},
],
},
queued: {
...queued,
currentInboundEventKind: "user_request",
originatingChannel: "discord",
originatingTo: "channel:C1",
run: {
...queued.run,
sourceReplyDeliveryMode: "message_tool_only",
},
},
});
expect(routeReplyMock).toHaveBeenCalledTimes(1);
expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
isError: true,
});
});
it("routes a terminal failure when an empty result exhausts model fallback", async () => {
runWithModelFallbackMock.mockImplementationOnce(
async (params: {
@@ -6198,6 +6233,45 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
expect(onObservedReplyDelivery).toHaveBeenCalledTimes(1);
});
it("routes retry diagnostics when message-tool evidence contains only progress", async () => {
const queued = baseQueuedRun("discord");
const onObservedReplyDelivery = vi.fn(async () => {});
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [],
didDeliverSourceReplyViaMessageTool: true,
messagingToolSentTexts: ["Still working…"],
messagingToolSentTargets: [
{
tool: "message",
provider: "discord",
to: "channel:C1",
sourceReplyFinal: false,
},
],
},
queued: {
...queued,
summaryLine: "stranded-reply-retry",
strandedReplyRetry: true,
originatingChannel: "discord",
originatingTo: "channel:C1",
run: {
...queued.run,
sourceReplyDeliveryMode: "message_tool_only",
},
} as FollowupRun,
runnerOverrides: { onObservedReplyDelivery },
});
expect(onBlockReply).not.toHaveBeenCalled();
expect(routeReplyMock).toHaveBeenCalledTimes(1);
expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe(
"I generated a reply but could not deliver it to this chat. Please try again.",
);
expect(onObservedReplyDelivery).not.toHaveBeenCalled();
});
it("routes retry diagnostics when message-tool sends to a non-source target", async () => {
const queued = baseQueuedRun("discord");
const { onBlockReply } = await runMessagingCase({

View File

@@ -13,9 +13,10 @@ import { getCliSessionBinding } from "../../agents/cli-session.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
hasCompletedSourceReplyDeliveryEvidence,
hasCommittedSourceReplyDeliveryEvidence,
hasVisibleAgentPayload,
hasVisibleOutboundDeliveryEvidence,
resolveExplicitFinalSourceReplyDeliveryEvidence,
} from "../../agents/embedded-agent-runner/delivery-evidence.js";
import {
hasDeliberateSilentTerminalReply,
@@ -181,12 +182,10 @@ function isStrandedReplyRetryFollowup(queued: FollowupRun): boolean {
function hasSuccessfulFollowupSourceReplyDelivery(params: {
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTargets?: EmbeddedAgentRunResult["messagingToolSentTargets"];
messagingToolSourceReplyPayloads?: EmbeddedAgentRunResult["messagingToolSourceReplyPayloads"];
}): boolean {
return (
params.didDeliverSourceReplyViaMessageTool === true ||
hasVisibleAgentPayload({ payloads: params.messagingToolSourceReplyPayloads })
);
return hasCompletedSourceReplyDeliveryEvidence(params);
}
function normalizeAssistantFinalDeliveryText(text: string): string {
@@ -1672,6 +1671,7 @@ export function createFollowupRunner(params: {
if (
hasSuccessfulFollowupSourceReplyDelivery({
didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool,
messagingToolSentTargets: runResult.messagingToolSentTargets,
messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads,
})
) {
@@ -1729,6 +1729,7 @@ export function createFollowupRunner(params: {
sendPolicyDenied: sourceReplyPolicy.sendPolicyDenied,
successfulSourceReplyDelivery: hasSuccessfulFollowupSourceReplyDelivery({
didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool,
messagingToolSentTargets: runResult.messagingToolSentTargets,
messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads,
}),
finalText: assistantFinalText,
@@ -1802,6 +1803,13 @@ export function createFollowupRunner(params: {
hasVisibleOutboundDeliveryEvidence(runResult) ||
hasCommittedSourceReplyDeliveryEvidence(runResult) ||
runResult.didSendDeterministicApprovalPrompt === true;
const completedSourceReplyDelivery = hasCompletedSourceReplyDeliveryEvidence(runResult);
const hasExplicitSourceReplyCompletion =
resolveExplicitFinalSourceReplyDeliveryEvidence(runResult) !== undefined;
const hasCompletedTerminalDelivery =
completedSourceReplyDelivery ||
(!hasExplicitSourceReplyCompletion && hasVisibleOutboundDeliveryEvidence(runResult)) ||
runResult.didSendDeterministicApprovalPrompt === true;
const hasDeliveryDestination = Boolean(
(isRoutableChannel(queued.originatingChannel) && queued.originatingTo) ||
opts?.onBlockReply,
@@ -1817,9 +1825,7 @@ export function createFollowupRunner(params: {
Surface: queued.originatingChannel,
};
const fallbackPayload = terminalRunFailed
? isInteractive &&
run.sourceReplyDeliveryMode !== "message_tool_only" &&
!hasCommittedDelivery
? isInteractive && !hasCompletedTerminalDelivery
? buildTerminalAgentRunFailureReplyPayload({
isHeartbeat: opts?.isHeartbeat,
sessionCtx: failureConversationContext,

View File

@@ -220,6 +220,7 @@ export function queueAgentHarnessMessage(
}
export { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js";
export {
cloneAgentRuntimeToolWithParameters,
logAgentRuntimeToolDiagnostics,
normalizeAgentRuntimeTools,
} from "../agents/runtime-plan/tools.js";

View File

@@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13161
},
"openClawDeveloperInstructions": {
"chars": 3262,
"roughTokens": 816
"chars": 3431,
"roughTokens": 858
},
"totalTextOnly": {
"chars": 27787,
"roughTokens": 6947
"chars": 27956,
"roughTokens": 6989
},
"totalWithDynamicToolsJson": {
"chars": 80433,
"roughTokens": 20109
"chars": 80602,
"roughTokens": 20151
},
"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. 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. Do not repeat that visible 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.

View File

@@ -212,16 +212,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13093
},
"openClawDeveloperInstructions": {
"chars": 2153,
"roughTokens": 539
"chars": 2322,
"roughTokens": 581
},
"totalTextOnly": {
"chars": 26269,
"roughTokens": 6568
"chars": 26438,
"roughTokens": 6610
},
"totalWithDynamicToolsJson": {
"chars": 78642,
"roughTokens": 19661
"chars": 78811,
"roughTokens": 19703
},
"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. 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. Do not repeat that visible 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.

View File

@@ -213,16 +213,16 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 13416
},
"openClawDeveloperInstructions": {
"chars": 2172,
"roughTokens": 543
"chars": 2341,
"roughTokens": 586
},
"totalTextOnly": {
"chars": 26808,
"roughTokens": 6702
"chars": 26977,
"roughTokens": 6745
},
"totalWithDynamicToolsJson": {
"chars": 80471,
"roughTokens": 20118
"chars": 80640,
"roughTokens": 20160
},
"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. 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. Do not repeat that visible 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.