From 6410e33cb01d7e4343450bb3d680bccb805d2b28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 23:09:25 -0700 Subject: [PATCH] refactor(ci): restore LOC ratchet headroom (#106063) * refactor(ci): restore TypeScript LOC ratchet * fix(ci): keep source reply item type internal --- .../src/app-server/agent-context-limits.ts | 37 ++++++++ .../src/app-server/dynamic-tool-build.ts | 62 +------------ .../codex/src/app-server/dynamic-tools.ts | 38 +------- .../app-server/message-tool-final-control.ts | 52 +++++++++++ .../src/app-server/native-execution-policy.ts | 11 +++ scripts/ts-max-loc-baseline-v2.json | 14 +-- .../delivery-evidence.ts | 13 +++ src/agents/embedded-agent-runner/run.ts | 1 - .../embedded-agent-runner/run/payloads.ts | 78 +++------------- .../run/source-reply-payloads.ts | 93 +++++++++++++++++++ src/agents/tools/message-tool-description.ts | 30 ++++++ src/agents/tools/message-tool.ts | 31 +------ src/auto-reply/reply/agent-runner.ts | 9 +- src/auto-reply/reply/followup-runner.ts | 10 +- 14 files changed, 270 insertions(+), 209 deletions(-) create mode 100644 extensions/codex/src/app-server/agent-context-limits.ts create mode 100644 extensions/codex/src/app-server/message-tool-final-control.ts create mode 100644 src/agents/embedded-agent-runner/run/source-reply-payloads.ts create mode 100644 src/agents/tools/message-tool-description.ts diff --git a/extensions/codex/src/app-server/agent-context-limits.ts b/extensions/codex/src/app-server/agent-context-limits.ts new file mode 100644 index 000000000000..e76858a3d6ec --- /dev/null +++ b/extensions/codex/src/app-server/agent-context-limits.ts @@ -0,0 +1,37 @@ +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +/** Resolves an agent override before falling back to the configured default. */ +export function resolveAgentContextLimitValue(params: { + config: EmbeddedRunAttemptParams["config"] | undefined; + agentId?: string; + key: string; +}): number | undefined { + const agents = readRecord(params.config?.agents); + const defaults = readRecord(readRecord(agents?.defaults)?.contextLimits); + const defaultValue = readPositiveInteger(defaults?.[params.key]); + if (!params.agentId) { + return defaultValue; + } + const list = agents?.list; + if (!Array.isArray(list)) { + return defaultValue; + } + const normalizedAgentId = normalizeAgentId(params.agentId); + const agent = list.find((entry) => { + const entryId = readRecord(entry)?.id; + return typeof entryId === "string" && normalizeAgentId(entryId) === normalizedAgentId; + }); + const agentValue = readPositiveInteger( + readRecord(readRecord(agent)?.contextLimits)?.[params.key], + ); + return agentValue ?? defaultValue; +} + +function readPositiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.floor(value); +} diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 1e6eba433d5e..2117912afd21 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -28,7 +28,9 @@ import { isForcedPrivateQaCodexRuntime, normalizeCodexDynamicToolName, } from "./dynamic-tool-profile.js"; +import { addCodexMessageToolOnlyFinalControl } from "./message-tool-final-control.js"; import { + resolveCodexNodeExecToolOverrides, resolveCodexNativeExecutionPolicy, type CodexNativeExecutionPolicy, } from "./native-execution-policy.js"; @@ -40,7 +42,6 @@ import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./w type OpenClawCodingToolsOptions = NonNullable< Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0] >; -type OpenClawExecOptions = NonNullable; /** Factory seam for constructing OpenClaw runtime tools without eagerly loading agent-harness. */ type OpenClawCodingToolsFactory = @@ -231,7 +232,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { ...buildEmbeddedAttemptToolRunContext(params), exec: { ...params.execOverrides, - ...resolveNodeExecToolOverrides(nativeExecutionPolicy), + ...resolveCodexNodeExecToolOverrides(nativeExecutionPolicy), config: params.config, elevated: params.bashElevated, }, @@ -881,51 +882,6 @@ function hideNodeExecDynamicToolParameters( ...(Array.isArray(rawRequired) ? { required: nextRequired } : {}), }; } -/** - * `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; - } - // allTools is attempt-fresh from createOpenClawCodingTools inside - // buildDynamicTools — never a shared/cached instance across attempts or - // delivery modes. Project the Codex-only `final` property in place so - // WeakMap ownership metadata stays attached without a public SDK clone helper. - for (const tool of tools) { - if (normalizeCodexDynamicToolName(tool.name) !== "message") { - continue; - } - tool.parameters = addCodexMessageToolOnlyFinalParameter(tool.parameters); - } - return tools; -} -function addCodexMessageToolOnlyFinalParameter(parameters: OpenClawDynamicTool["parameters"]) { - if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { - return parameters; - } - const schema = parameters as Record; - 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 { @@ -939,18 +895,6 @@ function resolveCodexNativeExecutionPolicyForDynamicTools( readRuntimeSessionEntry: true, }); } -function resolveNodeExecToolOverrides( - policy: CodexNativeExecutionPolicy, -): Pick | undefined { - if (policy.effectiveExecHost !== "node") { - return undefined; - } - const node = policy.node?.trim(); - return { - host: "node", - ...(node ? { node } : {}), - }; -} /** Applies a normalized tool allowlist while preserving shell aliases for exec/process. */ function filterCodexDynamicToolsForAllowlist( tools: T[], diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 2a423b9241b5..c7385bdc3dc4 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -44,12 +44,9 @@ import { import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm"; -import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { - asOptionalRecord as readRecord, - isRecord, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { resolveAgentContextLimitValue } from "./agent-context-limits.js"; import type { CodexDynamicToolsLoading } from "./config.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; import { @@ -1123,31 +1120,6 @@ function resolveCodexDynamicToolResultMaxChars( }); return configured ?? DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS; } -function resolveAgentContextLimitValue(params: { - config: EmbeddedRunAttemptParams["config"] | undefined; - agentId?: string; - key: string; -}): number | undefined { - const agents = readRecord(params.config?.agents); - const defaults = readRecord(readRecord(agents?.defaults)?.contextLimits); - const defaultValue = readPositiveInteger(defaults?.[params.key]); - if (!params.agentId) { - return defaultValue; - } - const list = agents?.list; - if (!Array.isArray(list)) { - return defaultValue; - } - const normalizedAgentId = normalizeAgentId(params.agentId); - const agent = list.find((entry) => { - const entryId = readRecord(entry)?.id; - return typeof entryId === "string" && normalizeAgentId(entryId) === normalizedAgentId; - }); - const agentValue = readPositiveInteger( - readRecord(readRecord(agent)?.contextLimits)?.[params.key], - ); - return agentValue ?? defaultValue; -} function composeAbortSignals(...signals: Array): AbortSignal { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); if (activeSignals.length === 0) { @@ -1283,12 +1255,6 @@ function extractInternalSourceReplyPayload( ? payload : undefined; } -function readPositiveInteger(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return undefined; - } - return Math.floor(value); -} function isCodexToolResultError(result: AgentToolResult): boolean { if (isToolResultError(result)) { return true; diff --git a/extensions/codex/src/app-server/message-tool-final-control.ts b/extensions/codex/src/app-server/message-tool-final-control.ts new file mode 100644 index 000000000000..54c850722d59 --- /dev/null +++ b/extensions/codex/src/app-server/message-tool-final-control.ts @@ -0,0 +1,52 @@ +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js"; + +type MutableDynamicTool = { + name: string; + parameters?: unknown; +}; + +/** + * `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. + */ +export function addCodexMessageToolOnlyFinalControl( + tools: T[], + sourceReplyDeliveryMode: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"], +): T[] { + if (sourceReplyDeliveryMode !== "message_tool_only") { + return tools; + } + // These tools are attempt-fresh. Mutating preserves their WeakMap ownership + // metadata without exposing a clone helper through the public plugin SDK. + for (const tool of tools) { + if (normalizeCodexDynamicToolName(tool.name) === "message") { + const mutableTool: MutableDynamicTool = tool; + mutableTool.parameters = addCodexMessageToolOnlyFinalParameter(mutableTool.parameters); + } + } + return tools; +} + +function addCodexMessageToolOnlyFinalParameter(parameters: unknown): unknown { + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + return parameters; + } + const schema = parameters as Record; + 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.", + }, + }, + }; +} diff --git a/extensions/codex/src/app-server/native-execution-policy.ts b/extensions/codex/src/app-server/native-execution-policy.ts index 6363e4987b96..39942a617037 100644 --- a/extensions/codex/src/app-server/native-execution-policy.ts +++ b/extensions/codex/src/app-server/native-execution-policy.ts @@ -31,6 +31,17 @@ export type CodexNativeExecutionPolicy = { blockReason?: string; }; +/** Projects node execution ownership into the runtime tool factory options. */ +export function resolveCodexNodeExecToolOverrides( + policy: CodexNativeExecutionPolicy, +): { host: "node"; node?: string } | undefined { + if (policy.effectiveExecHost !== "node") { + return undefined; + } + const node = policy.node?.trim(); + return { host: "node", ...(node ? { node } : {}) }; +} + /** Resolves node/gateway/sandbox execution ownership from overrides, session, agent, and config. */ export function resolveCodexNativeExecutionPolicy(params: { config?: OpenClawConfig; diff --git a/scripts/ts-max-loc-baseline-v2.json b/scripts/ts-max-loc-baseline-v2.json index 5f1092624c4f..a6448a114728 100644 --- a/scripts/ts-max-loc-baseline-v2.json +++ b/scripts/ts-max-loc-baseline-v2.json @@ -45,9 +45,9 @@ "extensions/codex/src/app-server/computer-use.ts": 1264, "extensions/codex/src/app-server/config.ts": 2492, "extensions/codex/src/app-server/context-engine-projection.ts": 514, - "extensions/codex/src/app-server/dynamic-tool-build.ts": 992, + "extensions/codex/src/app-server/dynamic-tool-build.ts": 936, "extensions/codex/src/app-server/dynamic-tool-execution.ts": 583, - "extensions/codex/src/app-server/dynamic-tools.ts": 1550, + "extensions/codex/src/app-server/dynamic-tools.ts": 1516, "extensions/codex/src/app-server/elicitation-bridge.ts": 993, "extensions/codex/src/app-server/event-projector.ts": 3499, "extensions/codex/src/app-server/native-subagent-monitor.ts": 1631, @@ -551,7 +551,7 @@ "src/agents/embedded-agent-runner/model.ts": 1995, "src/agents/embedded-agent-runner/replay-history.ts": 937, "src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts": 953, - "src/agents/embedded-agent-runner/run.ts": 5082, + "src/agents/embedded-agent-runner/run.ts": 5081, "src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts": 637, "src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts": 894, "src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts": 721, @@ -563,7 +563,7 @@ "src/agents/embedded-agent-runner/run/images.ts": 665, "src/agents/embedded-agent-runner/run/incomplete-turn.ts": 823, "src/agents/embedded-agent-runner/run/llm-idle-timeout.ts": 615, - "src/agents/embedded-agent-runner/run/payloads.ts": 1018, + "src/agents/embedded-agent-runner/run/payloads.ts": 968, "src/agents/embedded-agent-runner/runs.ts": 950, "src/agents/embedded-agent-runner/thinking.ts": 767, "src/agents/embedded-agent-runner/tool-result-context-guard.ts": 573, @@ -653,7 +653,7 @@ "src/agents/tools/image-tool.ts": 1095, "src/agents/tools/media-generate-background-shared.ts": 865, "src/agents/tools/media-tool-shared.ts": 689, - "src/agents/tools/message-tool.ts": 1756, + "src/agents/tools/message-tool.ts": 1733, "src/agents/tools/music-generate-tool.ts": 853, "src/agents/tools/pdf-tool.ts": 563, "src/agents/tools/session-status-tool.ts": 934, @@ -677,7 +677,7 @@ "src/auto-reply/reply/agent-runner-execution.ts": 3419, "src/auto-reply/reply/agent-runner-memory.ts": 1586, "src/auto-reply/reply/agent-runner-payloads.ts": 514, - "src/auto-reply/reply/agent-runner.ts": 2858, + "src/auto-reply/reply/agent-runner.ts": 2853, "src/auto-reply/reply/commands-acp/lifecycle.ts": 895, "src/auto-reply/reply/commands-acp/shared.ts": 539, "src/auto-reply/reply/commands-allowlist.ts": 607, @@ -693,7 +693,7 @@ "src/auto-reply/reply/dispatch-acp-delivery.ts": 542, "src/auto-reply/reply/dispatch-acp.ts": 812, "src/auto-reply/reply/dispatch-from-config.ts": 4562, - "src/auto-reply/reply/followup-runner.ts": 2065, + "src/auto-reply/reply/followup-runner.ts": 2059, "src/auto-reply/reply/get-reply-directives-apply.ts": 537, "src/auto-reply/reply/get-reply-directives.ts": 738, "src/auto-reply/reply/get-reply-inline-actions.ts": 641, diff --git a/src/agents/embedded-agent-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts index f5f7a476eae2..4b439b75d221 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.ts @@ -30,6 +30,7 @@ type AgentDeliveryEvidence = { errorMessage?: unknown; }; didSendViaMessagingTool?: unknown; + didSendDeterministicApprovalPrompt?: unknown; messagingToolSentTexts?: unknown; messagingToolSentMediaUrls?: unknown; messagingToolSentTargets?: unknown; @@ -86,6 +87,18 @@ export function hasCompletedSourceReplyDeliveryEvidence( ); } +/** Returns whether delivery evidence completes the current interactive turn. */ +export function hasCompletedTerminalDeliveryEvidence( + result: AgentDeliveryEvidence & SourceReplyDeliveryEvidence & ExplicitFinalSourceReplyEvidence, +): boolean { + const explicitFinal = resolveExplicitFinalSourceReplyDeliveryEvidence(result); + return ( + hasCompletedSourceReplyDeliveryEvidence(result) || + (explicitFinal === undefined && hasVisibleOutboundDeliveryEvidence(result)) || + result.didSendDeterministicApprovalPrompt === true + ); +} + function hasNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 6b2e3dcffc32..c19c1209a1d3 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -4317,7 +4317,6 @@ async function runEmbeddedAgentInternal( }; const finalAssistantVisibleText = resolveFinalAssistantVisibleText(attemptAssistant); const finalAssistantRawText = resolveFinalAssistantRawText(attemptAssistant); - const payloads = buildEmbeddedRunPayloads({ assistantTexts: attempt.assistantTexts, assistantMessageIndex: attempt.lastAssistantTextMessageIndex, diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index d29aa056f1b1..c0cdbd0b9273 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -54,7 +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"; +import { buildSourceReplyPayloadState } from "./source-reply-payloads.js"; type ToolMetaEntry = { toolName: string; meta?: string }; type ToolErrorWarningPolicy = { @@ -586,71 +586,21 @@ export function buildEmbeddedRunPayloads(params: { if (params.heartbeatToolResponse) { return [createHeartbeatToolResponsePayload(params.heartbeatToolResponse)]; } - const replyItems: Array<{ - text: string; - media?: string[]; - mediaUrl?: string; - isError?: boolean; - isReasoning?: boolean; - /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ - isCommentary?: boolean; - audioAsVoice?: boolean; - replyToId?: string; - replyToTag?: boolean; - replyToCurrent?: boolean; - presentation?: ReplyPayload["presentation"]; - interactive?: ReplyPayload["interactive"]; - channelData?: Record; - nonTerminalToolErrorWarning?: boolean; - sourceReplyMirror?: { - idempotencyKey?: string; - }; - }> = []; - // Internal source replies always need transcript/UI mirror payloads. Only a + // Internal source replies always need transcript/UI mirrors. Only a // message_tool_only run suppresses the separate automatic final answer. - const sourceReplyPayloads = params.messagingToolSourceReplyPayloads ?? []; - const sourceReplyStartIndex = replyItems.length; - sourceReplyPayloads.forEach((payload, index) => { - const text = normalizeOptionalString(payload.text) ?? ""; - const media = Array.from( - new Set([...(payload.mediaUrl ? [payload.mediaUrl] : []), ...(payload.mediaUrls ?? [])]), - ).filter((value) => value.trim().length > 0); - if ( - !text && - media.length === 0 && - !payload.presentation && - !payload.interactive && - !payload.channelData - ) { - return; - } - // Message-tool-only replies were already sent by the tool. Mirror them into - // the transcript while marking payloads so channel delivery suppresses a duplicate send. - replyItems.push({ - text, - ...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}), - ...(media.length ? { media } : {}), - ...(payload.audioAsVoice ? { audioAsVoice: true } : {}), - ...(payload.presentation ? { presentation: payload.presentation } : {}), - ...(payload.interactive ? { interactive: payload.interactive } : {}), - ...(payload.channelData ? { channelData: payload.channelData } : {}), - sourceReplyMirror: { - idempotencyKey: - payload.idempotencyKey ?? - (params.runId ? `${params.runId}:internal-source-reply:${index}` : undefined), - }, - }); + const { + replyItems, + hasSourceReplyPayload, + deliveredSourceReplyViaMessageTool, + explicitFinalSourceReply, + completedSourceReplyViaMessageTool, + } = buildSourceReplyPayloadState({ + payloads: params.messagingToolSourceReplyPayloads, + sentTargets: params.messagingToolSentTargets, + sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + didDeliverSourceReplyViaMessageTool: params.didDeliverSourceReplyViaMessageTool, + runId: params.runId, }); - const hasSourceReplyPayload = replyItems.length > sourceReplyStartIndex; - 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 || diff --git a/src/agents/embedded-agent-runner/run/source-reply-payloads.ts b/src/agents/embedded-agent-runner/run/source-reply-payloads.ts new file mode 100644 index 000000000000..d1182733b6e3 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/source-reply-payloads.ts @@ -0,0 +1,93 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js"; +import type { ReplyPayload } from "../../../auto-reply/reply-payload.js"; +import type { + MessagingToolSend, + MessagingToolSourceReplyPayload, +} from "../../embedded-agent-messaging.types.js"; +import { resolveExplicitFinalSourceReplyDeliveryEvidence } from "../delivery-evidence.js"; + +type EmbeddedRunReplyItem = { + text: string; + media?: string[]; + mediaUrl?: string; + isError?: boolean; + isReasoning?: boolean; + /** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */ + isCommentary?: boolean; + audioAsVoice?: boolean; + replyToId?: string; + replyToTag?: boolean; + replyToCurrent?: boolean; + presentation?: ReplyPayload["presentation"]; + interactive?: ReplyPayload["interactive"]; + channelData?: Record; + nonTerminalToolErrorWarning?: boolean; + sourceReplyMirror?: { idempotencyKey?: string }; +}; + +/** Builds transcript mirrors and completion evidence for message-tool source replies. */ +export function buildSourceReplyPayloadState(params: { + payloads?: MessagingToolSourceReplyPayload[]; + sentTargets?: MessagingToolSend[]; + sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + didDeliverSourceReplyViaMessageTool?: boolean; + runId?: string; +}): { + replyItems: EmbeddedRunReplyItem[]; + hasSourceReplyPayload: boolean; + deliveredSourceReplyViaMessageTool: boolean; + explicitFinalSourceReply: boolean | undefined; + completedSourceReplyViaMessageTool: boolean; +} { + const sourceReplyPayloads = params.payloads ?? []; + const replyItems = sourceReplyPayloads.flatMap((payload, index): EmbeddedRunReplyItem[] => { + const text = normalizeOptionalString(payload.text) ?? ""; + const media = Array.from( + new Set([...(payload.mediaUrl ? [payload.mediaUrl] : []), ...(payload.mediaUrls ?? [])]), + ).filter((value) => value.trim().length > 0); + if ( + !text && + media.length === 0 && + !payload.presentation && + !payload.interactive && + !payload.channelData + ) { + return []; + } + // These replies were already sent by the tool. Mirror them into the + // transcript while marking channel delivery to suppress a duplicate send. + return [ + { + text, + ...(payload.mediaUrl ? { mediaUrl: payload.mediaUrl } : {}), + ...(media.length ? { media } : {}), + ...(payload.audioAsVoice ? { audioAsVoice: true } : {}), + ...(payload.presentation ? { presentation: payload.presentation } : {}), + ...(payload.interactive ? { interactive: payload.interactive } : {}), + ...(payload.channelData ? { channelData: payload.channelData } : {}), + sourceReplyMirror: { + idempotencyKey: + payload.idempotencyKey ?? + (params.runId ? `${params.runId}:internal-source-reply:${index}` : undefined), + }, + }, + ]; + }); + const hasSourceReplyPayload = replyItems.length > 0; + const deliveredSourceReplyViaMessageTool = + params.sourceReplyDeliveryMode === "message_tool_only" && + params.didDeliverSourceReplyViaMessageTool === true; + const explicitFinalSourceReply = resolveExplicitFinalSourceReplyDeliveryEvidence({ + messagingToolSentTargets: params.sentTargets, + messagingToolSourceReplyPayloads: sourceReplyPayloads, + }); + return { + replyItems, + hasSourceReplyPayload, + deliveredSourceReplyViaMessageTool, + explicitFinalSourceReply, + completedSourceReplyViaMessageTool: + explicitFinalSourceReply ?? (hasSourceReplyPayload || deliveredSourceReplyViaMessageTool), + }; +} diff --git a/src/agents/tools/message-tool-description.ts b/src/agents/tools/message-tool-description.ts new file mode 100644 index 000000000000..005f9fa6fe9c --- /dev/null +++ b/src/agents/tools/message-tool-description.ts @@ -0,0 +1,30 @@ +import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; + +const MESSAGE_TOOL_THREAD_READ_HINT = ' Missing thread context: action="read" + threadId.'; + +export function appendMessageToolVisibleReplyHint( + description: string, + sourceReplyDeliveryMode?: SourceReplyDeliveryMode, + requireExplicitTarget?: boolean, +): string { + if (sourceReplyDeliveryMode !== "message_tool_only") { + return description; + } + const targetGuidance = requireExplicitTarget + ? "send needs target." + : "target defaults current source; set only elsewhere."; + return `${description} This turn visible reply: action="send" + message; ${targetGuidance} Final answer private.`; +} + +export function appendMessageToolReadHint( + description: string, + actions: Iterable, +): string { + for (const action of actions) { + if (action === "read") { + return `${description}${MESSAGE_TOOL_THREAD_READ_HINT}`; + } + } + return description; +} diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index b58d478b5acf..607c7543fbb4 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -88,10 +88,13 @@ import { resolveMessageActionAgentRuntimeIdentityToken, type GatewayCallOptions, } from "./gateway.js"; +import { + appendMessageToolReadHint, + appendMessageToolVisibleReplyHint, +} from "./message-tool-description.js"; import { isPollVoteEchoText } from "./poll-vote-echo.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; -const MESSAGE_TOOL_THREAD_READ_HINT = ' Missing thread context: action="read" + threadId.'; function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean { return action === "broadcast" || shouldApplyCrossContextMarker(action); } @@ -1335,32 +1338,6 @@ function buildMessageToolDescription(options?: { ); } -function appendMessageToolVisibleReplyHint( - description: string, - sourceReplyDeliveryMode?: SourceReplyDeliveryMode, - requireExplicitTarget?: boolean, -): string { - if (sourceReplyDeliveryMode !== "message_tool_only") { - return description; - } - const targetGuidance = requireExplicitTarget - ? "send needs target." - : "target defaults current source; set only elsewhere."; - return `${description} This turn visible reply: action="send" + message; ${targetGuidance} Final answer private.`; -} - -function appendMessageToolReadHint( - description: string, - actions: Iterable, -): string { - for (const action of actions) { - if (action === "read") { - return `${description}${MESSAGE_TOOL_THREAD_READ_HINT}`; - } - } - return description; -} - export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const loadConfigForTool = options?.getRuntimeConfig ?? getRuntimeConfig; const getScopedSecretTargetsForTool = diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index cb2b23698782..ff4b01656c73 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -13,10 +13,10 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isLikelyContextOverflowError } from "../../agents/embedded-agent-helpers/errors.js"; import { hasCompletedSourceReplyDeliveryEvidence, + hasCompletedTerminalDeliveryEvidence, hasCommittedSourceReplyDeliveryEvidence, hasVisibleCommittedMessagingToolDeliveryEvidence, hasVisibleOutboundDeliveryEvidence, - resolveExplicitFinalSourceReplyDeliveryEvidence, } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { hasDeliberateSilentTerminalReply } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; import { @@ -2011,8 +2011,6 @@ export async function runReplyAgent(params: { const committedMessagingToolSourceReplyDelivery = hasCommittedSourceReplyDeliveryEvidence(runResult); const completedSourceReplyDelivery = hasCompletedSourceReplyDeliveryEvidence(runResult); - const hasExplicitSourceReplyCompletion = - resolveExplicitFinalSourceReplyDeliveryEvidence(runResult) !== undefined; const visibleOutboundDelivery = hasVisibleOutboundDeliveryEvidence(runResult); const successfulSideEffectDelivery = successfulSourceReplyDelivery || @@ -2023,10 +2021,7 @@ export async function runReplyAgent(params: { hasSuccessfulTerminalSourceReplyDelivery({ blockReplyPipeline, directlySentBlockPayloads, - }) || - completedSourceReplyDelivery || - (!hasExplicitSourceReplyCompletion && visibleOutboundDelivery) || - runResult.didSendDeterministicApprovalPrompt === true; + }) || hasCompletedTerminalDeliveryEvidence(runResult); // Compaction notices are progress, not a terminal reply. Dispatcher-backed // delivery settles after this run returns, so it cannot prove turn completion here. const shouldDeliverTerminalFailure = Boolean( diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 70758bbdabee..5f5e7dd69331 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -14,9 +14,9 @@ import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { hasCompletedSourceReplyDeliveryEvidence, + hasCompletedTerminalDeliveryEvidence, hasCommittedSourceReplyDeliveryEvidence, hasVisibleOutboundDeliveryEvidence, - resolveExplicitFinalSourceReplyDeliveryEvidence, } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { hasDeliberateSilentTerminalReply, @@ -1801,13 +1801,7 @@ 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 hasCompletedTerminalDelivery = hasCompletedTerminalDeliveryEvidence(runResult); const hasDeliveryDestination = Boolean( (isRoutableChannel(queued.originatingChannel) && queued.originatingTo) || opts?.onBlockReply,