mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 19:01:42 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."));
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}}';
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -220,6 +220,7 @@ export function queueAgentHarnessMessage(
|
||||
}
|
||||
export { disposeRegisteredAgentHarnesses } from "../agents/harness/registry.js";
|
||||
export {
|
||||
cloneAgentRuntimeToolWithParameters,
|
||||
logAgentRuntimeToolDiagnostics,
|
||||
normalizeAgentRuntimeTools,
|
||||
} from "../agents/runtime-plan/tools.js";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user