diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index c1d4e5013d0..fd2eadc27b7 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" })); const sessionsDeleteSpy = vi.fn(); @@ -222,7 +223,7 @@ describe("subagent announce formatting", () => { expect(msg).toContain("[sessionId: child-session-usage]"); expect(msg).toContain("A completed subagent task is ready for user delivery."); expect(msg).toContain( - "Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.", + `Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`, ); expect(msg).toContain("step-0"); expect(msg).toContain("step-139"); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 5293d9c4524..db53e3eced6 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,4 +1,5 @@ import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, @@ -364,9 +365,9 @@ function buildAnnounceReplyInstruction(params: { return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`; } if (params.requesterIsSubagent) { - return "Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: NO_REPLY."; + return `Convert this completion into a concise internal orchestration update for your parent agent in your own words. Keep this internal context private (don't mention system/log/stats/session details or announce type). If this result is duplicate or no update is needed, reply ONLY: ${SILENT_REPLY_TOKEN}.`; } - return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.`; + return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the system message verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`; } export async function runSubagentAnnounceFlow(params: { diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index 0b552fd62d3..35db6055e56 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { buildSubagentSystemPrompt } from "./subagent-announce.js"; import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js"; @@ -367,7 +368,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("message: Send messages and channel actions"); expect(prompt).toContain("### message tool"); - expect(prompt).toContain("respond with ONLY: NO_REPLY"); + expect(prompt).toContain(`respond with ONLY: ${SILENT_REPLY_TOKEN}`); }); it("includes runtime provider capabilities when present", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 5c7d312d459..99f797e1749 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -112,7 +112,7 @@ function buildMessagingSection(params: { "- Cross-session messaging → use sessions_send(sessionKey, message)", "- Sub-agent orchestration → use subagents(action=list|steer|kill)", "- `[System Message] ...` blocks are internal context and are not user-visible by default.", - "- If a `[System Message]` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to NO_REPLY).", + `- If a \`[System Message]\` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to ${SILENT_REPLY_TOKEN}).`, "- Never use exec/curl for provider messaging; OpenClaw handles all routing internally.", params.availableTools.has("message") ? [ diff --git a/src/agents/tools/tts-tool.test.ts b/src/agents/tools/tts-tool.test.ts new file mode 100644 index 00000000000..fe9a6c1def9 --- /dev/null +++ b/src/agents/tools/tts-tool.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../auto-reply/tokens.js", () => ({ + SILENT_REPLY_TOKEN: "QUIET_TOKEN", +})); + +const { createTtsTool } = await import("./tts-tool.js"); + +describe("createTtsTool", () => { + it("uses SILENT_REPLY_TOKEN in guidance text", () => { + const tool = createTtsTool(); + + expect(tool.description).toContain("QUIET_TOKEN"); + expect(tool.description).not.toContain("NO_REPLY"); + }); +}); diff --git a/src/agents/tools/tts-tool.ts b/src/agents/tools/tts-tool.ts index 9296c649698..b11fa97d987 100644 --- a/src/agents/tools/tts-tool.ts +++ b/src/agents/tools/tts-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import type { AnyAgentTool } from "./common.js"; +import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { loadConfig } from "../../config/config.js"; import { textToSpeech } from "../../tts/tts.js"; import { readStringParam } from "./common.js"; @@ -20,8 +21,7 @@ export function createTtsTool(opts?: { return { label: "TTS", name: "tts", - description: - "Convert text to speech. Audio is delivered automatically from the tool result — reply with NO_REPLY after a successful call to avoid duplicate messages.", + description: `Convert text to speech. Audio is delivered automatically from the tool result — reply with ${SILENT_REPLY_TOKEN} after a successful call to avoid duplicate messages.`, parameters: TtsToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 09203ddba35..c1b60e47b7e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -138,6 +138,45 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("routes media-only tool results when summaries are suppressed", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + mocks.routeReply.mockClear(); + const cfg = {} as OpenClawConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "slack", + ChatType: "group", + AccountId: "acc-1", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts: GetReplyOptions | undefined, + _cfg: OpenClawConfig, + ) => { + expect(opts?.onToolResult).toBeDefined(); + await opts?.onToolResult?.({ + text: "NO_REPLY", + mediaUrls: ["https://example.com/tts-routed.opus"], + }); + return undefined; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(mocks.routeReply).toHaveBeenCalledTimes(1); + const routed = mocks.routeReply.mock.calls[0]?.[0] as { payload?: ReplyPayload } | undefined; + expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]); + expect(routed?.payload?.text).toBeUndefined(); + }); + it("provides onToolResult in DM sessions", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: false,