fix: guarantee manual subagent spawn sends completion message

This commit is contained in:
Peter Steinberger
2026-02-18 02:45:05 +01:00
parent 5bd95bef5a
commit e2dd827ca4
5 changed files with 35 additions and 19 deletions

View File

@@ -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<boolean> {
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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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();
});

View File

@@ -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,