Skip to content

Commit e80de46

Browse files
authored
fix(agents): preserve full subagent announce output
* fix(agents): preserve full subagent announce output * fix(agents): tighten subagent prefix fallback * fix(agents): broaden subagent prefix fallback
1 parent 8f75a4e commit e80de46

4 files changed

Lines changed: 293 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ Docs: https://docs.openclaw.ai
260260
- Plugins/install: keep managed npm-root security scans from treating earlier plugin `openclaw` peer links as failures, so one external plugin install cannot poison later official npm installs. Thanks @vincentkoc.
261261
- Memory LanceDB: allow installed-but-unconfigured plugin metadata to load so onboarding and setup flows can prompt for embedding config instead of failing the plugin registry first. Thanks @vincentkoc.
262262
- CLI/plugins: keep `plugins enable` and `plugins disable` from creating unconfigured channel config sections, so channel plugins with required setup fields no longer fail validation during lifecycle probes. Thanks @vincentkoc.
263+
- Agents/subagents: detect prefix-only completion announce replies and fall back to the captured child result so requester chats no longer lose most of long sub-agent reports silently. Fixes #76412. Thanks @inxaos.
263264
- Doctor/config: set `messages.groupChat.visibleReplies: "message_tool"` during compatibility repair for configured-channel configs that omit a visible-reply policy, so upgrades can persist the intended tool-only group/channel reply default. Thanks @kagura-agent.
264265
- Agents/sessions: keep delayed `sessions_send` A2A replies alive after soft wait-window timeouts, while preserving terminal run timeouts and avoiding stale target replies in requester sessions. Fixes #76443. Thanks @ryswork1993 and @vincentkoc.
265266
- TUI/Control UI: fix `/think` command showing only base thinking levels when the active session uses a different model from the default, so provider-specific levels like DeepSeek V4 Pro's `xhigh` and `max` are now visible and selectable. Fixes #76482. Thanks @amknight.

docs/tools/subagents.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ requester chat when the run finishes.
8383
</Accordion>
8484
<Accordion title="Manual-spawn delivery resilience">
8585
- OpenClaw tries direct `agent` delivery first with a stable idempotency key.
86-
- If direct delivery fails, it falls back to queue routing.
86+
- If the requester-agent completion turn fails, produces no visible output, or returns an obviously incomplete prefix of the captured child result, OpenClaw falls back to direct completion delivery from the captured child result.
87+
- If direct delivery cannot be used, it falls back to queue routing.
8788
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
8889
- 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.
8990

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ function createSendMessageMock() {
4444
})) as unknown as typeof runtimeSendMessage;
4545
}
4646

47+
const longChildCompletionOutput = [
48+
"34/34 tests pass, clean build. Now docker repro:",
49+
"Root cause: the requester's announce delivery accepted a prefix-only assistant payload as delivered.",
50+
"PR: https://github.com/openclaw/openclaw/pull/12345",
51+
"Verification: pnpm test src/agents/subagent-announce-delivery.test.ts passed with the regression enabled.",
52+
].join("\n");
53+
4754
async function deliverSlackThreadAnnouncement(params: {
4855
callGateway: typeof runtimeCallGateway;
4956
isActive: boolean;
@@ -583,6 +590,216 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
583590
expect(sendMessage).not.toHaveBeenCalled();
584591
});
585592

593+
it("uses direct fallback when announce-agent delivery returns only a child-result prefix", async () => {
594+
const callGateway = createGatewayMock({
595+
result: {
596+
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro:" }],
597+
},
598+
});
599+
const sendMessage = createSendMessageMock();
600+
const result = await deliverSlackThreadAnnouncement({
601+
callGateway,
602+
sendMessage,
603+
sessionId: "requester-session-4",
604+
isActive: false,
605+
expectsCompletionMessage: true,
606+
directIdempotencyKey: "announce-thread-fallback-prefix",
607+
internalEvents: [
608+
{
609+
type: "task_completion",
610+
source: "subagent",
611+
childSessionKey: "agent:worker:subagent:child",
612+
childSessionId: "child-session-id",
613+
announceType: "subagent task",
614+
taskLabel: "thread completion smoke",
615+
status: "ok",
616+
statusLabel: "completed successfully",
617+
result: longChildCompletionOutput,
618+
replyInstruction: "Summarize the result.",
619+
},
620+
],
621+
});
622+
623+
expect(result).toEqual(
624+
expect.objectContaining({
625+
delivered: true,
626+
path: "direct-thread-fallback",
627+
}),
628+
);
629+
expect(sendMessage).toHaveBeenCalledWith(
630+
expect.objectContaining({
631+
content: longChildCompletionOutput,
632+
idempotencyKey: "announce-thread-fallback-prefix",
633+
}),
634+
);
635+
});
636+
637+
it("uses direct fallback when announce-agent delivery returns a word-boundary child-result prefix", async () => {
638+
const callGateway = createGatewayMock({
639+
result: {
640+
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro" }],
641+
},
642+
});
643+
const sendMessage = createSendMessageMock();
644+
const result = await deliverSlackThreadAnnouncement({
645+
callGateway,
646+
sendMessage,
647+
sessionId: "requester-session-4",
648+
isActive: false,
649+
expectsCompletionMessage: true,
650+
directIdempotencyKey: "announce-thread-fallback-word-prefix",
651+
internalEvents: [
652+
{
653+
type: "task_completion",
654+
source: "subagent",
655+
childSessionKey: "agent:worker:subagent:child",
656+
childSessionId: "child-session-id",
657+
announceType: "subagent task",
658+
taskLabel: "thread completion smoke",
659+
status: "ok",
660+
statusLabel: "completed successfully",
661+
result: longChildCompletionOutput,
662+
replyInstruction: "Summarize the result.",
663+
},
664+
],
665+
});
666+
667+
expect(result).toEqual(
668+
expect.objectContaining({
669+
delivered: true,
670+
path: "direct-thread-fallback",
671+
}),
672+
);
673+
expect(sendMessage).toHaveBeenCalledWith(
674+
expect.objectContaining({
675+
content: longChildCompletionOutput,
676+
idempotencyKey: "announce-thread-fallback-word-prefix",
677+
}),
678+
);
679+
});
680+
681+
it("uses direct fallback when announce-agent delivery returns a mid-word child-result prefix", async () => {
682+
const callGateway = createGatewayMock({
683+
result: {
684+
payloads: [{ text: "34/34 tests pass, clean build. Now dock" }],
685+
},
686+
});
687+
const sendMessage = createSendMessageMock();
688+
const result = await deliverSlackThreadAnnouncement({
689+
callGateway,
690+
sendMessage,
691+
sessionId: "requester-session-4",
692+
isActive: false,
693+
expectsCompletionMessage: true,
694+
directIdempotencyKey: "announce-thread-fallback-midword-prefix",
695+
internalEvents: [
696+
{
697+
type: "task_completion",
698+
source: "subagent",
699+
childSessionKey: "agent:worker:subagent:child",
700+
childSessionId: "child-session-id",
701+
announceType: "subagent task",
702+
taskLabel: "thread completion smoke",
703+
status: "ok",
704+
statusLabel: "completed successfully",
705+
result: longChildCompletionOutput,
706+
replyInstruction: "Summarize the result.",
707+
},
708+
],
709+
});
710+
711+
expect(result).toEqual(
712+
expect.objectContaining({
713+
delivered: true,
714+
path: "direct-thread-fallback",
715+
}),
716+
);
717+
expect(sendMessage).toHaveBeenCalledWith(
718+
expect.objectContaining({
719+
content: longChildCompletionOutput,
720+
idempotencyKey: "announce-thread-fallback-midword-prefix",
721+
}),
722+
);
723+
});
724+
725+
it("keeps concise requester rewrites primary even when child output is long", async () => {
726+
const callGateway = createGatewayMock({
727+
result: {
728+
payloads: [{ text: "Tests passed and the PR is ready for review." }],
729+
},
730+
});
731+
const sendMessage = createSendMessageMock();
732+
const result = await deliverSlackThreadAnnouncement({
733+
callGateway,
734+
sendMessage,
735+
sessionId: "requester-session-4",
736+
isActive: false,
737+
expectsCompletionMessage: true,
738+
directIdempotencyKey: "announce-thread-rewrite-primary",
739+
internalEvents: [
740+
{
741+
type: "task_completion",
742+
source: "subagent",
743+
childSessionKey: "agent:worker:subagent:child",
744+
childSessionId: "child-session-id",
745+
announceType: "subagent task",
746+
taskLabel: "thread completion smoke",
747+
status: "ok",
748+
statusLabel: "completed successfully",
749+
result: longChildCompletionOutput,
750+
replyInstruction: "Summarize the result.",
751+
},
752+
],
753+
});
754+
755+
expect(result).toEqual(
756+
expect.objectContaining({
757+
delivered: true,
758+
path: "direct",
759+
}),
760+
);
761+
expect(sendMessage).not.toHaveBeenCalled();
762+
});
763+
764+
it("keeps copied complete-sentence requester summaries primary", async () => {
765+
const callGateway = createGatewayMock({
766+
result: {
767+
payloads: [{ text: "34/34 tests pass, clean build." }],
768+
},
769+
});
770+
const sendMessage = createSendMessageMock();
771+
const result = await deliverSlackThreadAnnouncement({
772+
callGateway,
773+
sendMessage,
774+
sessionId: "requester-session-4",
775+
isActive: false,
776+
expectsCompletionMessage: true,
777+
directIdempotencyKey: "announce-thread-copied-summary-primary",
778+
internalEvents: [
779+
{
780+
type: "task_completion",
781+
source: "subagent",
782+
childSessionKey: "agent:worker:subagent:child",
783+
childSessionId: "child-session-id",
784+
announceType: "subagent task",
785+
taskLabel: "thread completion smoke",
786+
status: "ok",
787+
statusLabel: "completed successfully",
788+
result: longChildCompletionOutput,
789+
replyInstruction: "Summarize the result.",
790+
},
791+
],
792+
});
793+
794+
expect(result).toEqual(
795+
expect.objectContaining({
796+
delivered: true,
797+
path: "direct",
798+
}),
799+
);
800+
expect(sendMessage).not.toHaveBeenCalled();
801+
});
802+
586803
it("uses a direct thread fallback when announce-agent delivery fails", async () => {
587804
const callGateway = vi.fn(async () => {
588805
throw new Error("UNAVAILABLE: gateway lost final output");

src/agents/subagent-announce-delivery.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js";
5454

5555
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
5656
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
57+
const MIN_COMPLETION_INTEGRITY_RESULT_LENGTH = 120;
58+
const MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH = 24;
59+
const MAX_COMPLETION_INTEGRITY_PREFIX_RATIO = 0.8;
5760

5861
type SubagentAnnounceDeliveryDeps = {
5962
callGateway: typeof callGateway;
@@ -565,6 +568,75 @@ function hasVisibleGatewayAgentPayload(response: unknown): boolean {
565568
);
566569
}
567570

571+
function collectVisibleGatewayAgentText(response: unknown): string {
572+
const result = getGatewayAgentResult(response);
573+
const payloads = result?.payloads;
574+
if (!Array.isArray(payloads)) {
575+
return "";
576+
}
577+
return payloads
578+
.flatMap((payload) => {
579+
if (!payload || typeof payload !== "object") {
580+
return [];
581+
}
582+
const text = (payload as { text?: unknown; isError?: unknown; isReasoning?: unknown }).text;
583+
if (typeof text !== "string") {
584+
return [];
585+
}
586+
if (
587+
(payload as { isError?: unknown; isReasoning?: unknown }).isError === true ||
588+
(payload as { isError?: unknown; isReasoning?: unknown }).isReasoning === true
589+
) {
590+
return [];
591+
}
592+
const trimmed = text.trim();
593+
return trimmed ? [trimmed] : [];
594+
})
595+
.join("\n")
596+
.trim();
597+
}
598+
599+
function normalizeCompletionIntegrityText(value: string): string {
600+
return value.replace(/\s+/g, " ").trim();
601+
}
602+
603+
function hasCompleteCompletionSummaryBoundary(value: string): boolean {
604+
const trimmed = value.replace(/[\s"')\]]+$/g, "");
605+
if (!trimmed) {
606+
return false;
607+
}
608+
return /[.!?]$/.test(trimmed);
609+
}
610+
611+
function hasIncompleteCompletionPrefix(response: unknown, completionFallbackText: string): boolean {
612+
const result = getGatewayAgentResult(response);
613+
if (!result || hasMessagingToolDeliveryEvidence(result)) {
614+
return false;
615+
}
616+
const expected = normalizeCompletionIntegrityText(completionFallbackText);
617+
if (expected.length < MIN_COMPLETION_INTEGRITY_RESULT_LENGTH) {
618+
return false;
619+
}
620+
const visible = normalizeCompletionIntegrityText(collectVisibleGatewayAgentText(response));
621+
if (
622+
visible.length < MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH ||
623+
visible.length >= expected.length * MAX_COMPLETION_INTEGRITY_PREFIX_RATIO
624+
) {
625+
return false;
626+
}
627+
return expected.startsWith(visible) && !hasCompleteCompletionSummaryBoundary(visible);
628+
}
629+
630+
function shouldSendCompletionFallback(response: unknown, completionFallbackText: string): boolean {
631+
if (!completionFallbackText) {
632+
return false;
633+
}
634+
if (!hasVisibleGatewayAgentPayload(response)) {
635+
return true;
636+
}
637+
return hasIncompleteCompletionPrefix(response, completionFallbackText);
638+
}
639+
568640
async function sendCompletionFallback(params: {
569641
cfg: OpenClawConfig;
570642
channel?: string;
@@ -840,7 +912,7 @@ async function sendSubagentAnnounceDirectly(params: {
840912
throw err;
841913
}
842914

843-
if (completionFallbackText && !hasVisibleGatewayAgentPayload(directAnnounceResponse)) {
915+
if (shouldSendCompletionFallback(directAnnounceResponse, completionFallbackText)) {
844916
const didFallback = await sendCompletionFallback({
845917
cfg,
846918
channel: deliveryTarget.channel,

0 commit comments

Comments
 (0)