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
This commit is contained in:
Vincent Koc
2026-05-03 22:33:00 -07:00
committed by GitHub
parent 8f75a4ebdf
commit e80de466e5
4 changed files with 293 additions and 2 deletions

View File

@@ -260,6 +260,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -83,7 +83,8 @@ requester chat when the run finishes.
</Accordion>
<Accordion title="Manual-spawn delivery resilience">
- OpenClaw tries direct `agent` delivery first with a stable idempotency key.
- If direct delivery fails, it falls back to queue routing.
- 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.
- If direct delivery cannot be used, it falls back to queue routing.
- If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up.
- 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.

View File

@@ -44,6 +44,13 @@ function createSendMessageMock() {
})) as unknown as typeof runtimeSendMessage;
}
const longChildCompletionOutput = [
"34/34 tests pass, clean build. Now docker repro:",
"Root cause: the requester's announce delivery accepted a prefix-only assistant payload as delivered.",
"PR: https://github.com/openclaw/openclaw/pull/12345",
"Verification: pnpm test src/agents/subagent-announce-delivery.test.ts passed with the regression enabled.",
].join("\n");
async function deliverSlackThreadAnnouncement(params: {
callGateway: typeof runtimeCallGateway;
isActive: boolean;
@@ -583,6 +590,216 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses direct fallback when announce-agent delivery returns only a child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro:" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-prefix",
}),
);
});
it("uses direct fallback when announce-agent delivery returns a word-boundary child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now docker repro" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-word-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-word-prefix",
}),
);
});
it("uses direct fallback when announce-agent delivery returns a mid-word child-result prefix", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build. Now dock" }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-fallback-midword-prefix",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct-thread-fallback",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: longChildCompletionOutput,
idempotencyKey: "announce-thread-fallback-midword-prefix",
}),
);
});
it("keeps concise requester rewrites primary even when child output is long", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "Tests passed and the PR is ready for review." }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-rewrite-primary",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct",
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("keeps copied complete-sentence requester summaries primary", async () => {
const callGateway = createGatewayMock({
result: {
payloads: [{ text: "34/34 tests pass, clean build." }],
},
});
const sendMessage = createSendMessageMock();
const result = await deliverSlackThreadAnnouncement({
callGateway,
sendMessage,
sessionId: "requester-session-4",
isActive: false,
expectsCompletionMessage: true,
directIdempotencyKey: "announce-thread-copied-summary-primary",
internalEvents: [
{
type: "task_completion",
source: "subagent",
childSessionKey: "agent:worker:subagent:child",
childSessionId: "child-session-id",
announceType: "subagent task",
taskLabel: "thread completion smoke",
status: "ok",
statusLabel: "completed successfully",
result: longChildCompletionOutput,
replyInstruction: "Summarize the result.",
},
],
});
expect(result).toEqual(
expect.objectContaining({
delivered: true,
path: "direct",
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
it("uses a direct thread fallback when announce-agent delivery fails", async () => {
const callGateway = vi.fn(async () => {
throw new Error("UNAVAILABLE: gateway lost final output");

View File

@@ -54,6 +54,9 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js";
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
const MIN_COMPLETION_INTEGRITY_RESULT_LENGTH = 120;
const MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH = 24;
const MAX_COMPLETION_INTEGRITY_PREFIX_RATIO = 0.8;
type SubagentAnnounceDeliveryDeps = {
callGateway: typeof callGateway;
@@ -565,6 +568,75 @@ function hasVisibleGatewayAgentPayload(response: unknown): boolean {
);
}
function collectVisibleGatewayAgentText(response: unknown): string {
const result = getGatewayAgentResult(response);
const payloads = result?.payloads;
if (!Array.isArray(payloads)) {
return "";
}
return payloads
.flatMap((payload) => {
if (!payload || typeof payload !== "object") {
return [];
}
const text = (payload as { text?: unknown; isError?: unknown; isReasoning?: unknown }).text;
if (typeof text !== "string") {
return [];
}
if (
(payload as { isError?: unknown; isReasoning?: unknown }).isError === true ||
(payload as { isError?: unknown; isReasoning?: unknown }).isReasoning === true
) {
return [];
}
const trimmed = text.trim();
return trimmed ? [trimmed] : [];
})
.join("\n")
.trim();
}
function normalizeCompletionIntegrityText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function hasCompleteCompletionSummaryBoundary(value: string): boolean {
const trimmed = value.replace(/[\s"')\]]+$/g, "");
if (!trimmed) {
return false;
}
return /[.!?]$/.test(trimmed);
}
function hasIncompleteCompletionPrefix(response: unknown, completionFallbackText: string): boolean {
const result = getGatewayAgentResult(response);
if (!result || hasMessagingToolDeliveryEvidence(result)) {
return false;
}
const expected = normalizeCompletionIntegrityText(completionFallbackText);
if (expected.length < MIN_COMPLETION_INTEGRITY_RESULT_LENGTH) {
return false;
}
const visible = normalizeCompletionIntegrityText(collectVisibleGatewayAgentText(response));
if (
visible.length < MIN_COMPLETION_INTEGRITY_PREFIX_LENGTH ||
visible.length >= expected.length * MAX_COMPLETION_INTEGRITY_PREFIX_RATIO
) {
return false;
}
return expected.startsWith(visible) && !hasCompleteCompletionSummaryBoundary(visible);
}
function shouldSendCompletionFallback(response: unknown, completionFallbackText: string): boolean {
if (!completionFallbackText) {
return false;
}
if (!hasVisibleGatewayAgentPayload(response)) {
return true;
}
return hasIncompleteCompletionPrefix(response, completionFallbackText);
}
async function sendCompletionFallback(params: {
cfg: OpenClawConfig;
channel?: string;
@@ -840,7 +912,7 @@ async function sendSubagentAnnounceDirectly(params: {
throw err;
}
if (completionFallbackText && !hasVisibleGatewayAgentPayload(directAnnounceResponse)) {
if (shouldSendCompletionFallback(directAnnounceResponse, completionFallbackText)) {
const didFallback = await sendCompletionFallback({
cfg,
channel: deliveryTarget.channel,