diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa2944a0b9..0467e3be29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. - Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive. +- Agents/media: tell async music and video completion agents when normal final replies are private, and send completion fallbacks directly to message-tool-only group/channel routes when the completion agent still only writes a private final reply, so generated media does not disappear behind the delivery contract. - CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit ` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index 046ed7ad16b..47c87d77dd6 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -102,7 +102,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat Main-session cron tasks use `silent` notify policy by default — they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session. - Session-backed `music_generate` and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Group/channel completions follow the normal visible-reply policy, so the agent uses the message tool when source delivery requires it. + Session-backed `music_generate` and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. Group/channel completions follow the normal visible-reply policy, so the agent uses the message tool when source delivery requires it. If the completion agent fails to produce message-tool delivery evidence in a tool-only route, OpenClaw sends the completion fallback directly to the original channel instead of leaving the media private. diff --git a/docs/tools/media-overview.md b/docs/tools/media-overview.md index ca2da284779..34fde3f7926 100644 --- a/docs/tools/media-overview.md +++ b/docs/tools/media-overview.md @@ -93,7 +93,9 @@ id immediately, and tracks the job in the task ledger. The agent continues responding to other messages while the job runs. When the provider finishes, OpenClaw wakes the agent with the generated media paths so it can tell the user and, when required by source-delivery policy, relay the result through -the message tool. +the message tool. For message-tool-only group/channel routes, OpenClaw treats +missing message-tool delivery evidence as a failed completion attempt and sends +the generated media fallback directly to the original channel. ## Speech-to-text and Voice Call diff --git a/docs/tools/music-generation.md b/docs/tools/music-generation.md index e0c5f61b742..ecc939ca009 100644 --- a/docs/tools/music-generation.md +++ b/docs/tools/music-generation.md @@ -16,7 +16,10 @@ For session-backed agent runs, OpenClaw starts music generation as a background task, tracks it in the task ledger, then wakes the agent again when the track is ready so the agent can tell the user and attach the finished audio. In group/channel chats that use message-tool-only visible -delivery, the agent relays the result through the message tool. +delivery, the agent relays the result through the message tool. If the +completion agent writes only a private final reply, OpenClaw falls back to a +direct channel send with the generated media. The completion wake explicitly +warns the agent that normal final replies are private in those routes. The built-in shared tool only appears when at least one music-generation diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 76acfd19bcc..83496572695 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -1202,7 +1202,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("requires message-tool delivery for generated media completions in default group routes", async () => { + it("falls back to direct send for generated media completions in default group routes", async () => { const callGateway = createGatewayMock({ result: { payloads: [ @@ -1241,8 +1241,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(result).toEqual( expect.objectContaining({ - delivered: false, - path: "direct", + delivered: true, + path: "direct-fallback", }), ); expect(callGateway).toHaveBeenCalledWith( @@ -1257,7 +1257,18 @@ describe("deliverSubagentAnnouncement completion delivery", () => { }), }), ); - expect(sendMessage).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + threadId: undefined, + content: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3", + requesterSessionKey: "agent:main:slack:channel:C123", + bestEffort: true, + idempotencyKey: "announce-channel-media-message-tool", + }), + ); }); it("uses a direct channel fallback when announce-agent returns no visible output", async () => { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index ba6d3b74f92..461cc127ecb 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -885,7 +885,9 @@ async function sendSubagentAnnounceDirectly(params: { }); const shouldDeliverAgentFinal = deliveryTarget.deliver && !requiresMessageToolDelivery; const completionFallbackText = - params.expectsCompletionMessage && shouldDeliverAgentFinal && !agentMediatedCompletion + params.expectsCompletionMessage && + deliveryTarget.deliver && + (!agentMediatedCompletion || requiresMessageToolDelivery) ? extractThreadCompletionFallbackText(params.internalEvents) : ""; const requesterActivity = resolveRequesterSessionActivity(canonicalRequesterSessionKey); @@ -1070,6 +1072,24 @@ async function sendSubagentAnnounceDirectly(params: { requiresMessageToolDelivery && !hasGatewayAgentMessagingToolDelivery(directAnnounceResponse) ) { + const didFallback = await sendCompletionFallback({ + cfg, + channel: deliveryTarget.channel, + to: deliveryTarget.to, + accountId: deliveryTarget.accountId, + threadId: deliveryTarget.threadId, + content: completionFallbackText, + requesterSessionKey: canonicalRequesterSessionKey, + bestEffortDeliver: params.bestEffortDeliver, + idempotencyKey: params.directIdempotencyKey, + signal: params.signal, + }); + if (didFallback) { + return { + delivered: true, + path: resolveCompletionFallbackPath(deliveryTarget.threadId), + }; + } return { delivered: false, path: "direct", diff --git a/src/agents/tools/media-generate-background-shared.ts b/src/agents/tools/media-generate-background-shared.ts index f4b29a38cf9..18872ba1c90 100644 --- a/src/agents/tools/media-generate-background-shared.ts +++ b/src/agents/tools/media-generate-background-shared.ts @@ -1,8 +1,10 @@ import crypto from "node:crypto"; +import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { deriveSessionChatTypeFromKey } from "../../sessions/session-chat-type-shared.js"; import { completeTaskRunByRunId, createRunningTaskRun, @@ -222,8 +224,18 @@ function failMediaGenerationTaskRun(params: { function buildMediaGenerationReplyInstruction(params: { status: "ok" | "error"; completionLabel: string; + requiresMessageToolDelivery: boolean; }) { if (params.status === "ok") { + if (params.requiresMessageToolDelivery) { + return [ + `The ${params.completionLabel} is ready for the original channel/group chat.`, + "This route requires message-tool delivery: the user will NOT see your normal assistant final reply.", + 'Call the message tool with action="send" to the original/current chat, put a short caption in the message, and attach the generated media paths from the result.', + `After the message tool succeeds, reply only ${SILENT_REPLY_TOKEN}.`, + "Do not put MEDIA: lines only in your final answer; that final answer is private in this chat.", + ].join(" "); + } return `Tell the user the ${params.completionLabel} is ready. If visible source delivery requires the message tool, send it there with the generated media attached.`; } return [ @@ -233,6 +245,39 @@ function buildMediaGenerationReplyInstruction(params: { ].join(" "); } +function inferMediaGenerationCompletionChatType( + handle: MediaGenerationTaskHandle, +): "direct" | "group" | "channel" | "unknown" { + const sessionKeyChatType = deriveSessionChatTypeFromKey(handle.requesterSessionKey); + if (sessionKeyChatType !== "unknown") { + return sessionKeyChatType; + } + const to = handle.requesterOrigin?.to?.trim().toLowerCase(); + if (to?.startsWith("group:")) { + return "group"; + } + if (to?.startsWith("channel:")) { + return "channel"; + } + if (to?.startsWith("dm:") || to?.startsWith("direct:")) { + return "direct"; + } + return "unknown"; +} + +function mediaGenerationCompletionRequiresMessageToolDelivery(params: { + config?: OpenClawConfig; + handle: MediaGenerationTaskHandle; +}): boolean { + const chatType = inferMediaGenerationCompletionChatType(params.handle); + if (chatType === "group" || chatType === "channel") { + const configuredMode = + params.config?.messages?.groupChat?.visibleReplies ?? params.config?.messages?.visibleReplies; + return configuredMode !== "automatic"; + } + return params.config?.messages?.visibleReplies === "message_tool"; +} + async function wakeMediaGenerationTaskCompletion(params: { config?: OpenClawConfig; handle: MediaGenerationTaskHandle | null; @@ -266,6 +311,10 @@ async function wakeMediaGenerationTaskCompletion(params: { replyInstruction: buildMediaGenerationReplyInstruction({ status: params.status, completionLabel: params.completionLabel, + requiresMessageToolDelivery: mediaGenerationCompletionRequiresMessageToolDelivery({ + config: params.config, + handle: params.handle, + }), }), }, ]; diff --git a/src/agents/tools/music-generate-background.test.ts b/src/agents/tools/music-generate-background.test.ts index 260ba3411da..e2208c77f1e 100644 --- a/src/agents/tools/music-generate-background.test.ts +++ b/src/agents/tools/music-generate-background.test.ts @@ -95,6 +95,50 @@ describe("music generate background helpers", () => { expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalled(); }); + it("warns channel completion agents that normal final replies are private", async () => { + announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({ + delivered: true, + path: "direct", + }); + const completion = createMediaCompletionFixture({ + runId: "tool:music_generate:abc", + taskLabel: "night-drive synthwave", + result: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3", + mediaUrls: ["/tmp/generated-night-drive.mp3"], + }); + + await wakeMusicGenerationTaskCompletion({ + ...completion, + handle: { + ...completion.handle, + requesterSessionKey: "agent:main:discord:channel:C123", + }, + }); + + expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledWith( + expect.objectContaining({ + internalEvents: expect.arrayContaining([ + expect.objectContaining({ + replyInstruction: expect.stringContaining( + "the user will NOT see your normal assistant final reply", + ), + }), + ]), + }), + ); + expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledWith( + expect.objectContaining({ + internalEvents: expect.arrayContaining([ + expect.objectContaining({ + replyInstruction: expect.stringContaining( + "Do not put MEDIA: lines only in your final answer", + ), + }), + ]), + }), + ); + }); + it("queues a completion event when direct send is enabled globally", async () => { taskDeliveryRuntimeMocks.sendMessage.mockResolvedValue({ channel: "discord",