From efbefceb0e2e83f75dc7b0b14839fcb6973b6ee0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 15 Jun 2026 14:58:39 +0800 Subject: [PATCH] fix(cron): preserve explicit delivery and timeout semantics --- src/agents/cli-runner/prepare.test.ts | 4 +++ src/agents/cli-runner/prepare.ts | 2 ++ src/agents/cli-runner/types.ts | 1 + src/cron/isolated-agent/run-executor.ts | 1 + .../run.message-tool-policy.test.ts | 30 +++++++++++++++++++ .../run.meta-error-status.test.ts | 6 +++- src/cron/isolated-agent/run.ts | 19 ++++++------ src/cron/service/execution-errors.ts | 13 ++++++-- src/cron/service/timer.regression.test.ts | 13 ++++++++ src/cron/service/timer.ts | 4 ++- src/gateway/mcp-http.loopback-runtime.ts | 2 ++ src/gateway/mcp-http.request.ts | 8 +++-- src/gateway/mcp-http.runtime.ts | 2 ++ src/gateway/mcp-http.test.ts | 16 ++++++++-- src/gateway/mcp-http.ts | 1 + src/gateway/tool-resolution.ts | 2 ++ 16 files changed, 106 insertions(+), 18 deletions(-) diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 27e954b7b584..5f46d237249d 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -126,6 +126,8 @@ function createTestMcpLoopbackServerConfig(port: number) { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-require-explicit-message-target": + "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", }, }, }, @@ -1586,6 +1588,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { currentMessageId: "reply-message-1", currentInboundAudio: true, sourceReplyDeliveryMode: "message_tool_only", + requireExplicitMessageTarget: true, }); expect(context.preparedBackend.env).toMatchObject({ @@ -1596,6 +1599,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: "true", OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only", + OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: "true", }); } finally { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 9a64d134b406..6577ef489d7b 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -385,6 +385,8 @@ export async function prepareCliRunContext( OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: params.currentInboundAudio === true ? "true" : "", OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "", OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "", + OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: + params.requireExplicitMessageTarget === true ? "true" : "", } : undefined, warn: (message) => cliBackendLog.warn(message), diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 724a22e89b06..cf6da85cee81 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -75,6 +75,7 @@ export type RunCliAgentParams = { jobId?: string; extraSystemPrompt?: string; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + requireExplicitMessageTarget?: boolean; silentReplyPromptMode?: SilentReplyPromptMode; allowEmptyAssistantReplyAsSilent?: boolean; /** Static portion of extraSystemPrompt (excluding per-message inbound metadata) for session reuse hashing. */ diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 95d8706939aa..13ba94e2d9c2 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -249,6 +249,7 @@ export function createCronPromptExecutor(params: { skillsSnapshot: params.skillsSnapshot, messageChannel, sourceReplyDeliveryMode, + requireExplicitMessageTarget: params.sourceDelivery.messageTool.requireExplicitTarget, abortSignal: params.abortSignal, onExecutionStarted: params.onExecutionStarted, onExecutionPhase: params.onExecutionPhase, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index f35d3082bda6..18c5af68b10e 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -9,6 +9,7 @@ import { cleanupDirectCronSessionMock, dispatchCronDeliveryMock, getChannelPluginMock, + isCliProviderMock, isHeartbeatOnlyResponseMock, loadRunCronIsolatedAgentTurn, makeCronSession, @@ -20,6 +21,7 @@ import { resolveCronDeliveryPlanMock, resolveDeliveryTargetMock, restoreFastTestEnv, + runCliAgentMock, runEmbeddedAgentMock, } from "./run.test-harness.js"; @@ -347,6 +349,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { }, messageToolEnabled: true, messageToolForced: false, + requireExplicitMessageTarget: true, requireExplicitMessageTargetEvidence: true, directFallback: true, }), @@ -705,10 +708,37 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expectEmbeddedRunFields({ sourceReplyDeliveryMode: undefined, forceMessageTool: false, + requireExplicitMessageTarget: true, messageChannel: "messagechat", messageTo: "123", currentChannelId: "123", }); + expect(expectEmbeddedRunPrompt()).toContain("with an explicit target"); + }); + + it("requires explicit message targets for CLI-backed announce delivery", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan()); + isCliProviderMock.mockReturnValue(true); + runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeAnnounceMessageToolJob(), + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(1); + expectRecordFields( + getMockCallArg(runCliAgentMock, 0, 0, "CLI run"), + { + messageChannel: "messagechat", + requireExplicitMessageTarget: true, + }, + "CLI run params", + ); }); it("keeps automatic exec completion notifications when announce delivery is active", async () => { diff --git a/src/cron/isolated-agent/run.meta-error-status.test.ts b/src/cron/isolated-agent/run.meta-error-status.test.ts index 643035889711..e0b7d16870b7 100644 --- a/src/cron/isolated-agent/run.meta-error-status.test.ts +++ b/src/cron/isolated-agent/run.meta-error-status.test.ts @@ -69,7 +69,11 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => { it("keeps cron timeout result when executor rejects after the cron abort signal fires", async () => { const abortController = new AbortController(); - abortController.abort("cron: job execution timed out (last phase: model_call_started)"); + const timeoutError = new Error( + "cron: job execution timed out (last phase: model_call_started)", + ); + timeoutError.name = "TimeoutError"; + abortController.abort(timeoutError); runWithModelFallbackMock.mockRejectedValueOnce( new Error( 'All models failed (2): openai/gpt-5.5: Command lane "cron-nested" task timed out after 330000ms (timeout)', diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 5a9b08370886..1e5ac882d05c 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -46,6 +46,7 @@ import { createCronRunDiagnosticsFromError, mergeCronRunDiagnostics, } from "../run-diagnostics.js"; +import { resolveCronAbortReasonText } from "../service/execution-errors.js"; import type { CronAgentExecutionPhaseUpdate, CronAgentExecutionStarted, @@ -341,6 +342,7 @@ function resolveCronSourceDeliveryPlan(params: { target, messageToolEnabled: true, messageToolForced: false, + requireExplicitMessageTarget: true, requireExplicitMessageTargetEvidence: true, directFallback: true, skipFallbackWhenMessageToolSentToTarget: params.resolvedDelivery.ok, @@ -425,14 +427,16 @@ function appendCronDeliveryInstruction(params: { deliveryRequested: boolean; messageToolEnabled: boolean; resolvedDeliveryOk: boolean; + requireExplicitMessageTarget: boolean; }) { if (!params.deliveryRequested) { return params.commandBody; } if (params.messageToolEnabled) { - const targetHint = params.resolvedDeliveryOk - ? "for the current chat" - : "with an explicit target"; + const targetHint = + params.requireExplicitMessageTarget || !params.resolvedDeliveryOk + ? "with an explicit target" + : "for the current chat"; return `${params.commandBody}\n\nUse the message tool if you need to notify the user directly ${targetHint}. If you do not send directly, your final plain-text reply will be delivered automatically.`.trim(); } return `${params.commandBody}\n\nReturn your response as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); @@ -833,6 +837,7 @@ async function prepareCronRunContext(params: { toolsAllow: agentPayload?.toolsAllow, }), resolvedDeliveryOk: resolvedDelivery.ok, + requireExplicitMessageTarget: sourceDelivery.messageTool.requireExplicitTarget, }); const skillsSnapshot = await resolveCronSkillsSnapshot({ @@ -1271,12 +1276,8 @@ export async function runCronIsolatedAgentTurn(params: { const admittedLifecycleGeneration = getAgentEventLifecycleGeneration(); const abortSignal = params.abortSignal ?? params.signal; const isAborted = () => abortSignal?.aborted === true; - const abortReason = () => { - const reason = abortSignal?.reason; - return typeof reason === "string" && reason.trim() - ? reason.trim() - : "cron: job execution timed out"; - }; + const abortReason = () => + resolveCronAbortReasonText(abortSignal?.reason) ?? "cron: job execution timed out"; const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; const prepared = await prepareCronRunContext({ input: params, isFastTestEnv }); if (!prepared.ok) { diff --git a/src/cron/service/execution-errors.ts b/src/cron/service/execution-errors.ts index 6a6e97b7ff46..e27870ed9843 100644 --- a/src/cron/service/execution-errors.ts +++ b/src/cron/service/execution-errors.ts @@ -39,12 +39,19 @@ export function preExecutionTimeoutErrorMessage(execution?: CronAgentExecutionSt } /** Extracts a human timeout/abort reason, falling back to the canonical cron timeout text. */ -export function abortErrorMessage(signal?: AbortSignal): string { - const reason = signal?.reason; +export function resolveCronAbortReasonText(reason: unknown): string | undefined { if (typeof reason === "string" && reason.trim()) { return reason.trim(); } - return timeoutErrorMessage(); + if (reason instanceof Error && reason.message.trim()) { + return reason.message.trim(); + } + return undefined; +} + +/** Extracts a human timeout/abort reason, falling back to the canonical cron timeout text. */ +export function abortErrorMessage(signal?: AbortSignal): string { + return resolveCronAbortReasonText(signal?.reason) ?? timeoutErrorMessage(); } function isAbortError(err: unknown): boolean { diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 5e0b69d64d84..8b0fe617aaf4 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -2527,6 +2527,7 @@ describe("cron service timer regressions", () => { let now = scheduledAt; const wallStart = Date.now(); let abortWallMs: number | undefined; + let abortReason: unknown; const started = createDeferred(); const state = createCronServiceState({ @@ -2553,6 +2554,7 @@ describe("cron service timer regressions", () => { } if (abortSignal.aborted) { abortWallMs = Date.now(); + abortReason = abortSignal.reason; resolve(); return; } @@ -2560,6 +2562,7 @@ describe("cron service timer regressions", () => { "abort", () => { abortWallMs = Date.now(); + abortReason = abortSignal.reason; resolve(); }, { once: true }, @@ -2582,6 +2585,10 @@ describe("cron service timer regressions", () => { const elapsedMs = (abortWallMs ?? Date.now()) - wallStart; expect(elapsedMs).toBeGreaterThanOrEqual(timeoutSeconds * 1_000); + expect(abortReason).toMatchObject({ + name: "TimeoutError", + message: "cron: job execution timed out", + }); const job = state.store?.jobs.find((entry) => entry.id === "timeout-fraction-29774"); expect(job?.state.lastStatus).toBe("error"); @@ -2872,6 +2879,7 @@ describe("cron service timer regressions", () => { let now = scheduledAt; const started = createDeferred(); let abortObserved = false; + let abortReason: unknown; const cleanupTimedOutAgentRun = vi.fn(async () => {}); const onIsolatedAgentSetupTimeout = vi.fn(); const state = createCronServiceState({ @@ -2912,6 +2920,7 @@ describe("cron service timer regressions", () => { "abort", () => { abortObserved = true; + abortReason = abortSignal.reason; }, { once: true }, ); @@ -2931,6 +2940,10 @@ describe("cron service timer regressions", () => { expect(job.state.lastStatus).toBe("error"); expect(job.state.lastError).toContain("stalled before execution start"); expect(job.state.lastError).toContain("context-engine"); + expect(abortReason).toMatchObject({ + name: "TimeoutError", + message: expect.stringContaining("context-engine"), + }); expect(cleanupTimedOutAgentRun).toHaveBeenCalledTimes(1); const cleanupArgs = requireRecord(firstMockArg(cleanupTimedOutAgentRun)); expect(requireRecord(cleanupArgs.job).id).toBe("isolated-pre-model-timeout-74803"); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index a82476bb2a10..a96ac52e6ac9 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -227,7 +227,9 @@ export async function executeJobCoreWithTimeout( const triggerTimeout = (reason: string) => { timeoutReason = reason; if (!runAbortController.signal.aborted) { - runAbortController.abort(reason); + const timeoutError = new Error(reason); + timeoutError.name = "TimeoutError"; + runAbortController.abort(timeoutError); } resolveTimeout?.(timeoutMarker); }; diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index cea42c1ee379..6b5e7e906411 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -51,6 +51,8 @@ export function createMcpLoopbackServerConfig(port: number) { "x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}", "x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}", "x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", + "x-openclaw-require-explicit-message-target": + "${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}", }, }, }, diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 5f58a76e4203..ef8ca8c41f2b 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -58,6 +58,7 @@ type McpRequestContext = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + requireExplicitMessageTarget: boolean | undefined; senderIsOwner: boolean | undefined; }; @@ -78,7 +79,7 @@ function normalizeMcpSourceReplyDeliveryMode( return trimmed === "automatic" || trimmed === "message_tool_only" ? trimmed : undefined; } -function normalizeMcpCurrentInboundAudio(value: string | undefined): boolean | undefined { +function normalizeMcpBooleanHeader(value: string | undefined): boolean | undefined { const trimmed = normalizeOptionalString(value); return trimmed ? isTruthyEnvValue(trimmed) : undefined; } @@ -365,7 +366,7 @@ export function resolveMcpRequestContext( currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")), currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")), currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")), - currentInboundAudio: normalizeMcpCurrentInboundAudio( + currentInboundAudio: normalizeMcpBooleanHeader( getHeader(req, "x-openclaw-current-inbound-audio"), ), accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")), @@ -373,6 +374,9 @@ export function resolveMcpRequestContext( sourceReplyDeliveryMode: normalizeMcpSourceReplyDeliveryMode( getHeader(req, "x-openclaw-source-reply-delivery-mode"), ), + requireExplicitMessageTarget: normalizeMcpBooleanHeader( + getHeader(req, "x-openclaw-require-explicit-message-target"), + ), senderIsOwner: auth.senderIsOwner, }; } diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index b568b374fa25..bf934c32b775 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -36,6 +36,7 @@ type McpLoopbackScopeParams = { accountId: string | undefined; inboundEventKind: InboundEventKind | undefined; sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined; + requireExplicitMessageTarget?: boolean; senderIsOwner: boolean | undefined; }; @@ -70,6 +71,7 @@ export class McpLoopbackToolCache { params.accountId ?? "", params.inboundEventKind ?? "", params.sourceReplyDeliveryMode ?? "", + params.requireExplicitMessageTarget === true ? "explicit-message-target" : "", params.senderIsOwner === true ? "owner" : params.senderIsOwner === false diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 079b1fa88f1e..46a0f1bb3140 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -31,6 +31,7 @@ type ScopedToolsCall = { currentInboundAudio?: boolean; inboundEventKind?: string; sourceReplyDeliveryMode?: string; + requireExplicitMessageTarget?: boolean; senderIsOwner?: boolean; surface?: string; excludeToolNames?: Iterable; @@ -680,6 +681,7 @@ describe("mcp loopback server", () => { "x-openclaw-current-inbound-audio": "true", "x-openclaw-inbound-event-kind": "room_event", "x-openclaw-source-reply-delivery-mode": "message_tool_only", + "x-openclaw-require-explicit-message-target": "true", }), body: mcpToolsListBody(), }); @@ -695,6 +697,7 @@ describe("mcp loopback server", () => { expect(call.currentInboundAudio).toBe(true); expect(call.inboundEventKind).toBe("room_event"); expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(call.requireExplicitMessageTarget).toBe(true); expect(call.surface).toBe("loopback"); expect(Array.from(call.excludeToolNames ?? [])).toEqual([ "read", @@ -706,12 +709,13 @@ describe("mcp loopback server", () => { ]); }); - it("keeps loopback tool cache entries separate by inbound event kind, delivery mode, and inbound audio", async () => { + it("keeps loopback tool cache entries separate by inbound event, delivery, audio, and target policy", async () => { const { runtime } = await startLoopbackServerForTest(); const sendToolsList = async ( inboundEventKind: string, sourceReplyDeliveryMode?: string, currentInboundAudio?: boolean, + requireExplicitMessageTarget?: boolean, ) => await sendLoopbackToolsList({ token: runtime?.ownerToken, @@ -723,6 +727,9 @@ describe("mcp loopback server", () => { ? { "x-openclaw-source-reply-delivery-mode": sourceReplyDeliveryMode } : {}), ...(currentInboundAudio ? { "x-openclaw-current-inbound-audio": "true" } : {}), + ...(requireExplicitMessageTarget + ? { "x-openclaw-require-explicit-message-target": "true" } + : {}), }, }); @@ -730,12 +737,14 @@ describe("mcp loopback server", () => { expect((await sendToolsList("room_event")).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only")).status).toBe(200); expect((await sendToolsList("room_event", "message_tool_only", true)).status).toBe(200); + expect((await sendToolsList("room_event", "message_tool_only", true, true)).status).toBe(200); - expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(4); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(5); expect(getScopedToolsCall(0).inboundEventKind).toBe("user_request"); expect(getScopedToolsCall(1).inboundEventKind).toBe("room_event"); expect(getScopedToolsCall(2).sourceReplyDeliveryMode).toBe("message_tool_only"); expect(getScopedToolsCall(3).currentInboundAudio).toBe(true); + expect(getScopedToolsCall(4).requireExplicitMessageTarget).toBe(true); }); it("keeps explicit non-owner and unknown-owner loopback cache entries separate", () => { @@ -1277,6 +1286,9 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe( "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}", ); + expect( + config.mcpServers?.openclaw?.headers?.["x-openclaw-require-explicit-message-target"], + ).toBe("${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}"); expect(config.mcpServers?.openclaw?.headers).not.toHaveProperty("x-openclaw-sender-is-owner"); }); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 36c991887f55..7d8b70be888d 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -199,6 +199,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ accountId: requestContext.accountId, inboundEventKind: requestContext.inboundEventKind, sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode, + requireExplicitMessageTarget: requestContext.requireExplicitMessageTarget, senderIsOwner: requestContext.senderIsOwner, }); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 7184e300d678..f7914b95aaa8 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -48,6 +48,7 @@ export function resolveGatewayScopedTools(params: { accountId?: string; inboundEventKind?: InboundEventKind; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + requireExplicitMessageTarget?: boolean; agentTo?: string; agentThreadId?: string; senderIsOwner?: boolean; @@ -168,6 +169,7 @@ export function resolveGatewayScopedTools(params: { currentThreadTs: params.currentThreadTs ?? params.agentThreadId, currentMessageId: params.currentMessageId, currentInboundAudio: params.currentInboundAudio, + requireExplicitMessageTarget: params.requireExplicitMessageTarget, senderIsOwner: params.senderIsOwner, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, allowMediaInvokeCommands: params.allowMediaInvokeCommands,