Skip to content

Commit d887eb8

Browse files
steipetegalinilievyozakura-avamoeedahmed
committed
fix(agents): harden subagent completion delivery
Co-authored-by: Galin Iliev <galini@microsoft.com> Co-authored-by: Ava Daigo <theavadaigo@gmail.com> Co-authored-by: Moeed Ahmed <moeedahmed@users.noreply.github.com>
1 parent d801d27 commit d887eb8

6 files changed

Lines changed: 183 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.
910
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
1011

1112
## 2026.5.17

docs/tools/subagents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ requester chat when the run finishes.
8686
<Accordion title="Manual-spawn delivery resilience">
8787
- OpenClaw hands completions back to the requester session through an `agent` turn with a stable idempotency key.
8888
- If the requester run is still active, OpenClaw first tries to wake/steer that run instead of starting a second visible reply path.
89+
- If an active requester cannot be woken, OpenClaw falls back to a requester-agent handoff with the same completion context instead of dropping the announce.
8990
- If the requester-agent completion handoff fails or produces no visible output, OpenClaw treats delivery as failed and falls back to queue routing/retry. It does not raw-send the child result directly to the external chat.
91+
- Group and channel completion handoffs follow the same message-tool-only visible reply policy as normal group/channel turns, so the requester agent must use the message tool when required.
9092
- If direct handoff cannot be used, it falls back to queue routing.
9193
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
9294
- Completion delivery keeps the resolved requester route: thread-bound or conversation-bound completion routes win when available; if the completion origin only provides a channel, OpenClaw fills the missing target/account from the requester session's resolved route (`lastChannel` / `lastTo` / `lastAccountId`) so direct delivery still works.

src/agents/subagent-announce-delivery.test.ts

Lines changed: 158 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,8 +1083,12 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
10831083
expect(sendMessage).not.toHaveBeenCalled();
10841084
});
10851085

1086-
it("does not queue when an active Telegram requester cannot be woken directly", async () => {
1087-
const callGateway = createGatewayMock();
1086+
it("falls back to requester-agent handoff when an active Telegram requester cannot be woken", async () => {
1087+
const callGateway = createGatewayMock({
1088+
result: {
1089+
payloads: [{ text: "child completion output" }],
1090+
},
1091+
});
10881092
const sendMessage = createSendMessageMock();
10891093
const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(false);
10901094
const result = await deliverTelegramDirectMessageCompletion({
@@ -1109,47 +1113,95 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
11091113
});
11101114

11111115
expectRecordFields(result, {
1112-
delivered: false,
1116+
delivered: true,
11131117
path: "direct",
11141118
phases: [
11151119
{
11161120
phase: "direct-primary",
1117-
delivered: false,
1121+
delivered: true,
11181122
path: "direct",
1119-
error:
1120-
"active requester session could not be woken: queue_message_failed reason=not_streaming sessionId=requester-session-telegram gatewayHealth=live",
1121-
},
1122-
{
1123-
phase: "steer-fallback",
1124-
delivered: false,
1125-
path: "none",
11261123
error: undefined,
11271124
},
11281125
],
11291126
});
1130-
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2);
1131-
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith(
1132-
1,
1133-
"requester-session-telegram",
1134-
"child done",
1135-
{
1136-
steeringMode: "all",
1137-
debounceMs: 500,
1138-
},
1139-
);
1140-
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenNthCalledWith(
1141-
2,
1127+
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(1);
1128+
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledWith(
11421129
"requester-session-telegram",
11431130
"child done",
11441131
{
11451132
steeringMode: "all",
11461133
debounceMs: 500,
11471134
},
11481135
);
1149-
expect(callGateway).not.toHaveBeenCalled();
1136+
expect(callGateway).toHaveBeenCalledTimes(1);
11501137
expect(sendMessage).not.toHaveBeenCalled();
11511138
});
11521139

1140+
it("uses steer fallback when a completion handoff has no visible output", async () => {
1141+
const callGateway = createGatewayMock({
1142+
result: {
1143+
payloads: [],
1144+
},
1145+
});
1146+
const queueEmbeddedPiMessageWithOutcome = vi
1147+
.fn<QueueEmbeddedPiMessageWithOutcome>()
1148+
.mockImplementationOnce((sessionId: string) => ({
1149+
queued: false,
1150+
sessionId,
1151+
reason: "not_streaming",
1152+
gatewayHealth: "live",
1153+
}))
1154+
.mockImplementationOnce((sessionId: string) => ({
1155+
queued: true,
1156+
sessionId,
1157+
target: "embedded_run",
1158+
gatewayHealth: "live",
1159+
}));
1160+
const result = await deliverSlackChannelAnnouncement({
1161+
callGateway,
1162+
sessionId: "requester-session-channel",
1163+
isActive: false,
1164+
expectsCompletionMessage: true,
1165+
directIdempotencyKey: "announce-channel-empty-direct-steer-fallback",
1166+
queueEmbeddedPiMessageWithOutcome,
1167+
internalEvents: [
1168+
{
1169+
type: "task_completion",
1170+
source: "subagent",
1171+
childSessionKey: "agent:worker:subagent:child",
1172+
childSessionId: "child-session-id",
1173+
announceType: "subagent task",
1174+
taskLabel: "channel completion smoke",
1175+
status: "ok",
1176+
statusLabel: "completed successfully",
1177+
result: "child completion output",
1178+
replyInstruction: "Summarize the result.",
1179+
},
1180+
],
1181+
});
1182+
1183+
expectRecordFields(result, {
1184+
delivered: true,
1185+
path: "steered",
1186+
phases: [
1187+
{
1188+
phase: "direct-primary",
1189+
delivered: false,
1190+
path: "direct",
1191+
error: "completion agent did not produce a visible reply",
1192+
},
1193+
{
1194+
phase: "steer-fallback",
1195+
delivered: true,
1196+
path: "steered",
1197+
error: undefined,
1198+
},
1199+
],
1200+
});
1201+
expect(queueEmbeddedPiMessageWithOutcome).toHaveBeenCalledTimes(2);
1202+
expect(callGateway).toHaveBeenCalledTimes(1);
1203+
});
1204+
11531205
it("reports failure when announce-agent returns no visible output", async () => {
11541206
const callGateway = createGatewayMock({
11551207
result: {
@@ -1846,6 +1898,88 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
18461898
expect(sendMessage).not.toHaveBeenCalled();
18471899
});
18481900

1901+
it("requires message-tool delivery for channel subagent completions", async () => {
1902+
const callGateway = createGatewayMock({
1903+
result: {
1904+
payloads: [{ text: "The subagent is done." }],
1905+
},
1906+
});
1907+
const result = await deliverSlackChannelAnnouncement({
1908+
callGateway,
1909+
sessionId: "requester-session-channel",
1910+
isActive: false,
1911+
expectsCompletionMessage: true,
1912+
directIdempotencyKey: "announce-channel-subagent-message-tool",
1913+
sourceTool: "subagent_announce",
1914+
internalEvents: [
1915+
{
1916+
type: "task_completion",
1917+
source: "subagent",
1918+
childSessionKey: "agent:worker:subagent:child",
1919+
childSessionId: "child-session-id",
1920+
announceType: "subagent task",
1921+
taskLabel: "channel completion smoke",
1922+
status: "ok",
1923+
statusLabel: "completed successfully",
1924+
result: "child completion output",
1925+
replyInstruction: "Summarize the result.",
1926+
},
1927+
],
1928+
});
1929+
1930+
expectRecordFields(result, {
1931+
delivered: false,
1932+
path: "direct",
1933+
error: "completion agent did not deliver through the message tool",
1934+
});
1935+
expectGatewayAgentParams(callGateway, {
1936+
deliver: false,
1937+
channel: "slack",
1938+
accountId: "acct-1",
1939+
to: "channel:C123",
1940+
threadId: undefined,
1941+
sourceReplyDeliveryMode: "message_tool_only",
1942+
});
1943+
});
1944+
1945+
it("keeps automatic final delivery for direct subagent completions", async () => {
1946+
const callGateway = createGatewayMock({
1947+
result: {
1948+
payloads: [{ text: "The subagent is done." }],
1949+
},
1950+
});
1951+
const result = await deliverDiscordDirectMessageCompletion({
1952+
callGateway,
1953+
sourceTool: "subagent_announce",
1954+
internalEvents: [
1955+
{
1956+
type: "task_completion",
1957+
source: "subagent",
1958+
childSessionKey: "agent:worker:subagent:child",
1959+
childSessionId: "child-session-id",
1960+
announceType: "subagent task",
1961+
taskLabel: "direct completion smoke",
1962+
status: "ok",
1963+
statusLabel: "completed successfully",
1964+
result: "child completion output",
1965+
replyInstruction: "Summarize the result.",
1966+
},
1967+
],
1968+
});
1969+
1970+
expectRecordFields(result, {
1971+
delivered: true,
1972+
path: "direct",
1973+
});
1974+
expectGatewayAgentParams(callGateway, {
1975+
deliver: true,
1976+
channel: "discord",
1977+
accountId: "acct-1",
1978+
to: "dm:U123",
1979+
threadId: undefined,
1980+
});
1981+
});
1982+
18491983
it("falls back to the external requester route when completion origin is internal", async () => {
18501984
const callGateway = createGatewayMock({
18511985
result: {

src/agents/subagent-announce-delivery.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { completionRequiresMessageToolDelivery } from "../auto-reply/reply/completion-delivery-policy.js";
12
import type { OpenClawConfig } from "../config/types.openclaw.js";
23
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
34
import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
@@ -60,6 +61,7 @@ const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
6061
const AGENT_MEDIATED_COMPLETION_TOOLS = new Set([
6162
"image_generate",
6263
"music_generate",
64+
"subagent_announce",
6365
"video_generate",
6466
]);
6567

@@ -634,7 +636,17 @@ async function sendSubagentAnnounceDirectly(params: {
634636
sourceTool: params.sourceTool,
635637
});
636638
const expectedMediaUrls = collectExpectedMediaFromInternalEvents(params.internalEvents);
637-
const requiresMessageToolDelivery = agentMediatedCompletion && expectedMediaUrls.length > 0;
639+
const requiresMessageToolDelivery =
640+
agentMediatedCompletion &&
641+
(expectedMediaUrls.length > 0 ||
642+
completionRequiresMessageToolDelivery({
643+
cfg,
644+
requesterSessionKey: params.requesterSessionKey,
645+
targetRequesterSessionKey: canonicalRequesterSessionKey,
646+
requesterEntry,
647+
directOrigin: effectiveDirectOrigin,
648+
requesterSessionOrigin,
649+
}));
638650
const completionSourceReplyDeliveryMode = requiresMessageToolDelivery
639651
? "message_tool_only"
640652
: undefined;
@@ -670,20 +682,13 @@ async function sendSubagentAnnounceDirectly(params: {
670682
path: "steered",
671683
};
672684
}
673-
const shouldFallbackToForcedAgentHandoff =
674-
requiresMessageToolDelivery && wakeOutcome.reason === "source_reply_delivery_mode_mismatch";
675-
if (requesterActivity.isActive && !shouldFallbackToForcedAgentHandoff) {
676-
// Active requester sessions should receive completion data through their
677-
// running agent turn. If wake fails, let the dispatch layer steer/retry;
678-
// do not bypass the requester agent with raw child output.
679-
return {
680-
delivered: false,
681-
path: "direct",
682-
error: formatQueueWakeFailureError(
685+
if (requesterActivity.isActive) {
686+
defaultRuntime.log(
687+
`[warn] Active requester session could not be woken for subagent completion; falling back to requester-agent handoff: ${formatQueueWakeFailureError(
683688
"active requester session could not be woken",
684689
wakeOutcome,
685-
),
686-
};
690+
)}`,
691+
);
687692
}
688693
}
689694
if (params.signal?.aborted) {

src/agents/subagent-announce.format.e2e.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,9 @@ describe("subagent announce formatting", () => {
518518
expect(msg).toContain(
519519
"If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update.",
520520
);
521+
expect(msg).toContain(
522+
"If the runtime marks this route as message-tool-only, send visible output with the message tool first",
523+
);
521524
expect(msg).toContain("Keep this internal context private");
522525
expect(call?.params?.internalEvents?.[0]?.type).toBe("task_completion");
523526
expect(call?.params?.internalEvents?.[0]?.taskLabel).toBe("do thing");

src/agents/subagent-announce.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function buildAnnounceReplyInstruction(params: {
8888
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}.`;
8989
}
9090
if (params.expectsCompletionMessage) {
91-
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. Keep this internal context private (don't mention system/log/stats/session details or announce type).`;
91+
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. If the runtime marks this route as message-tool-only, send visible output with the message tool first, then reply ONLY: ${SILENT_REPLY_TOKEN}. Keep this internal context private (don't mention system/log/stats/session details or announce type).`;
9292
}
9393
return `A completed ${params.announceType} is ready for parent review. Review/verify the result above before deciding whether the original task is done. If additional action is required, continue the task or record a follow-up; otherwise send a truthful user-facing update. Keep this internal context private (don't mention system/log/stats/session details or announce type), and do not copy the internal event text verbatim. Reply ONLY: ${SILENT_REPLY_TOKEN} if this exact result was already delivered to the user in this same turn.`;
9494
}

0 commit comments

Comments
 (0)