diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 999cec35efa..56cfdf52848 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -360,7 +360,11 @@ function buildAnnounceReplyInstruction(params: { remainingActiveSubagentRuns: number; requesterIsSubagent: boolean; announceType: SubagentAnnounceType; + expectsCompletionMessage?: boolean; }): string { + if (params.expectsCompletionMessage) { + 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).`; + } if (params.remainingActiveSubagentRuns > 0) { const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs"; 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.`; @@ -387,8 +391,10 @@ export async function runSubagentAnnounceFlow(params: { label?: string; outcome?: SubagentRunOutcome; announceType?: SubagentAnnounceType; + expectsCompletionMessage?: boolean; }): Promise { let didAnnounce = false; + const expectsCompletionMessage = params.expectsCompletionMessage === true; let shouldDeleteChildSession = params.cleanup === "delete"; try { let targetRequesterSessionKey = params.requesterSessionKey; @@ -506,7 +512,7 @@ export async function runSubagentAnnounceFlow(params: { let triggerMessage = ""; let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); - let requesterIsSubagent = requesterDepth >= 1; + let requesterIsSubagent = !expectsCompletionMessage && requesterDepth >= 1; // If the requester subagent has already finished, bubble the announce to its // requester (typically main) so descendant completion is not silently lost. // BUT: only fallback if the parent SESSION is deleted, not just if the current @@ -559,6 +565,7 @@ export async function runSubagentAnnounceFlow(params: { remainingActiveSubagentRuns, requesterIsSubagent, announceType, + expectsCompletionMessage, }); const statsLine = await buildCompactAnnounceStatsLine({ sessionKey: params.childSessionKey, @@ -580,20 +587,22 @@ export async function runSubagentAnnounceFlow(params: { childSessionKey: params.childSessionKey, childRunId: params.childRunId, }); - const queued = await maybeQueueSubagentAnnounce({ - requesterSessionKey: targetRequesterSessionKey, - announceId, - triggerMessage, - summaryLine: taskLabel, - requesterOrigin: targetRequesterOrigin, - }); - if (queued === "steered") { - didAnnounce = true; - return true; - } - if (queued === "queued") { - didAnnounce = true; - return true; + if (!expectsCompletionMessage) { + const queued = await maybeQueueSubagentAnnounce({ + requesterSessionKey: targetRequesterSessionKey, + announceId, + triggerMessage, + summaryLine: taskLabel, + requesterOrigin: targetRequesterOrigin, + }); + if (queued === "steered") { + didAnnounce = true; + return true; + } + if (queued === "queued") { + didAnnounce = true; + return true; + } } // Send to the requester session. For nested subagents this is an internal diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index a19f1bd55a3..22cb5b9bd36 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -30,6 +30,7 @@ export type SubagentRunRecord = { cleanupCompletedAt?: number; cleanupHandled?: boolean; suppressAnnounceReason?: "steer-restart" | "killed"; + expectsCompletionMessage?: boolean; /** Number of times announce delivery has been attempted and returned false (deferred). */ announceRetryCount?: number; /** Timestamp of the last announce retry attempt (for backoff). */ @@ -91,6 +92,7 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor requesterOrigin, requesterDisplayKey: entry.requesterDisplayKey, task: entry.task, + expectsCompletionMessage: entry.expectsCompletionMessage, timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, cleanup: entry.cleanup, waitForCompletion: false, @@ -478,6 +480,7 @@ export function registerSubagentRun(params: { label?: string; model?: string; runTimeoutSeconds?: number; + expectsCompletionMessage?: boolean; }) { const now = Date.now(); const cfg = loadConfig(); @@ -494,6 +497,7 @@ export function registerSubagentRun(params: { requesterDisplayKey: params.requesterDisplayKey, task: params.task, cleanup: params.cleanup, + expectsCompletionMessage: params.expectsCompletionMessage, label: params.label, model: params.model, runTimeoutSeconds, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 878440fba65..97f2ad9d072 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -25,6 +25,7 @@ export type SpawnSubagentParams = { thinking?: string; runTimeoutSeconds?: number; cleanup?: "delete" | "keep"; + expectsCompletionMessage?: boolean; }; export type SpawnSubagentContext = { @@ -318,6 +319,7 @@ export async function spawnSubagentDirect( label: label || undefined, model: resolvedModel, runTimeoutSeconds, + expectsCompletionMessage: params.expectsCompletionMessage === true, }); return { diff --git a/src/auto-reply/reply/commands-subagents-spawn.test.ts b/src/auto-reply/reply/commands-subagents-spawn.test.ts index 57a2b47de35..9f4b6158a91 100644 --- a/src/auto-reply/reply/commands-subagents-spawn.test.ts +++ b/src/auto-reply/reply/commands-subagents-spawn.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js"; import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js"; const hoisted = vi.hoisted(() => { const spawnSubagentDirectMock = vi.fn(); @@ -94,6 +94,7 @@ describe("/subagents spawn command", () => { expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.agentId).toBe("beta"); expect(spawnParams.cleanup).toBe("keep"); + expect(spawnParams.expectsCompletionMessage).toBe(true); expect(spawnCtx.agentSessionKey).toBeDefined(); }); diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index f56a9ed43e6..ce7f40e84e9 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -1,7 +1,8 @@ import crypto from "node:crypto"; +import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import type { CommandHandler } from "./commands-types.js"; import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js"; import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; -import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import { clearSubagentRunSteerRestart, listSubagentRunsForRequester, @@ -35,7 +36,6 @@ import { } from "../../shared/subagents-format.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { stopSubagentsForRequester } from "./abort.js"; -import type { CommandHandler } from "./commands-types.js"; import { clearSessionQueues } from "./queue.js"; import { formatRunLabel, formatRunStatus, sortSubagentRuns } from "./subagents-utils.js"; @@ -674,7 +674,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo } const result = await spawnSubagentDirect( - { task, agentId, model, thinking, cleanup: "keep" }, + { task, agentId, model, thinking, cleanup: "keep", expectsCompletionMessage: true }, { agentSessionKey: requesterKey, agentChannel: params.command.channel,