fix: drop silent parent replies while subagents are pending (#69942)

Drop bare parent NO_REPLY payloads while spawned subagents are pending, preserving quiet parent turns until child completion delivers the real reply.\n\nThanks @neeravmakwana.
This commit is contained in:
Neerav Makwana
2026-04-22 15:04:38 -04:00
committed by GitHub
parent aee9f476c8
commit 5462d4d5c5
6 changed files with 145 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.
- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.
- Agents/subagents: drop bare `NO_REPLY` from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana.
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.
- Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.

View File

@@ -167,6 +167,10 @@ Defaults live under `agents.defaults.silentReply` and
`agents.defaults.silentReplyRewrite`; `surfaces.<id>.silentReply` and
`surfaces.<id>.silentReplyRewrite` can override them per surface.
When the parent session has one or more pending spawned subagent runs, bare
silent replies are dropped on all surfaces instead of being rewritten, so the
parent stays quiet until the child completion event delivers the real reply.
## Related
- [Streaming](/concepts/streaming) — real-time message delivery

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ContextEngine, SubagentEndReason } from "../context-engine/types.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { registerPendingSpawnedChildrenQuery } from "../infra/outbound/pending-spawn-query.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { importRuntimeModule } from "../shared/runtime-import.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
@@ -924,3 +925,20 @@ export function getLatestSubagentRunByChildSessionKey(
export function initSubagentRegistry() {
restoreSubagentRunsOnce();
}
// Let the shared outbound plan treat bare silent replies as dropped (instead
// of rewriting them to visible fallback text) when the parent session has at
// least one pending spawned child whose completion will deliver the real
// reply. Uses the pending-descendant count so runs that have ended but whose
// announce/cleanup is still in flight continue to suppress rewriting; without
// this the window between `completeSubagentRun` setting `endedAt` and
// `startSubagentAnnounceCleanupFlow` finishing could briefly re-enable
// fallback chatter. Runtime-enforced, so it does not rely on agent prompt
// compliance.
registerPendingSpawnedChildrenQuery((sessionKey) => {
const key = sessionKey?.trim();
if (!key) {
return false;
}
return countPendingDescendantRuns(key) > 0;
});

View File

@@ -14,6 +14,7 @@ import {
projectOutboundPayloadPlanForMirror,
projectOutboundPayloadPlanForOutbound,
} from "./payloads.js";
import { registerPendingSpawnedChildrenQuery } from "./pending-spawn-query.js";
function resolveMirrorProjection(payloads: readonly ReplyPayload[]) {
const normalized = normalizeReplyPayloadsForDelivery(payloads);
@@ -275,6 +276,54 @@ describe("normalizeReplyPayloadsForDelivery", () => {
]);
});
describe("pending spawned subagent children", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: { direct: "disallow", group: "allow", internal: "allow" },
silentReplyRewrite: { direct: true },
},
},
};
const planSilent = (sessionKey: string, hasPendingSpawnedChildren?: boolean) =>
projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
cfg,
sessionKey,
surface: "telegram",
hasPendingSpawnedChildren,
}),
);
it("drops bare silent replies when the context flag is set", () => {
expect(planSilent("agent:main:telegram:direct:123", true)).toEqual([]);
});
it("drops bare silent replies via the registered runtime query", () => {
const sessionKey = "agent:main:telegram:direct:456";
const previousQuery = registerPendingSpawnedChildrenQuery((key) => key === sessionKey);
try {
expect(planSilent(sessionKey)).toEqual([]);
} finally {
registerPendingSpawnedChildrenQuery(previousQuery);
}
});
it("falls back to the rewrite path when the query throws", () => {
const previousQuery = registerPendingSpawnedChildrenQuery(() => {
throw new Error("registry unavailable");
});
try {
const delivery = planSilent("agent:main:telegram:direct:789");
expect(delivery).toHaveLength(1);
expect(delivery[0]?.text).toBeTruthy();
expect(delivery[0]?.text).not.toMatch(/NO_REPLY/i);
} finally {
registerPendingSpawnedChildrenQuery(previousQuery);
}
});
});
it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => {
const cfg: OpenClawConfig = {
agents: {

View File

@@ -21,6 +21,7 @@ import {
resolveSilentReplyRewriteText,
type SilentReplyConversationType,
} from "../../shared/silent-reply-policy.js";
import { resolvePendingSpawnedChildren } from "./pending-spawn-query.js";
export type NormalizedOutboundPayload = {
text: string;
@@ -56,6 +57,14 @@ type OutboundPayloadPlanContext = {
sessionKey?: string;
surface?: string;
conversationType?: SilentReplyConversationType;
/**
* When true, bare silent payloads are dropped instead of being rewritten to
* visible fallback text. Set by callers that know the parent session has at
* least one pending spawned child whose completion will deliver the real
* reply. If omitted, the outbound plan consults the registered runtime query
* (see `pending-spawn-query.ts`).
*/
hasPendingSpawnedChildren?: boolean;
};
export type OutboundPayloadMirror = {
@@ -178,6 +187,8 @@ export function createOutboundPayloadPlan(
surface: context.surface,
conversationType: context.conversationType,
});
const hasPendingSpawnedChildren =
context.hasPendingSpawnedChildren ?? resolvePendingSpawnedChildren(context.sessionKey);
const prepared: PreparedOutboundPayloadPlanEntry[] = [];
for (const payload of payloads) {
const entry = createOutboundPayloadPlanEntry(payload);
@@ -208,7 +219,11 @@ export function createOutboundPayloadPlan(
});
continue;
}
if (hasVisibleNonSilentContent || resolvedSilentReplySettings.policy === "allow") {
if (
hasVisibleNonSilentContent ||
resolvedSilentReplySettings.policy === "allow" ||
hasPendingSpawnedChildren
) {
continue;
}
if (!resolvedSilentReplySettings.rewrite) {

View File

@@ -0,0 +1,57 @@
import { createHash } from "node:crypto";
import { createSubsystemLogger } from "../../logging/subsystem.js";
/**
* Synchronous predicate: does `sessionKey` have pending spawned subagent runs?
* Runs on the outbound plan hot path, so implementations must be cheap/bounded
* (default in `subagent-registry.ts` is an in-memory map lookup). Internal to
* core; not re-exported through `openclaw/plugin-sdk`.
*/
export type PendingSpawnedChildrenQuery = (sessionKey?: string) => boolean;
const log = createSubsystemLogger("outbound/pending-spawn");
const THROW_LOG_INTERVAL_MS = 60_000;
let lastThrowLogAt = 0;
let pendingSpawnedChildrenQuery: PendingSpawnedChildrenQuery | undefined;
export function registerPendingSpawnedChildrenQuery(
query: PendingSpawnedChildrenQuery | undefined,
): PendingSpawnedChildrenQuery | undefined {
const previous = pendingSpawnedChildrenQuery;
pendingSpawnedChildrenQuery = query;
return previous;
}
function summarizeError(err: unknown): { name: string; message: string } {
if (err instanceof Error) {
return { name: err.name, message: err.message };
}
return { name: "Unknown", message: typeof err === "string" ? err : "non-error throw" };
}
function hashSessionKey(key: string | undefined): string | undefined {
const trimmed = key?.trim();
if (!trimmed) {
return undefined;
}
return createHash("sha256").update(trimmed).digest("hex").slice(0, 12);
}
export function resolvePendingSpawnedChildren(sessionKey: string | undefined): boolean {
if (!pendingSpawnedChildrenQuery) {
return false;
}
try {
return pendingSpawnedChildrenQuery(sessionKey);
} catch (err) {
const now = Date.now();
if (now - lastThrowLogAt >= THROW_LOG_INTERVAL_MS) {
lastThrowLogAt = now;
log.warn("pending-spawn query threw; defaulting to false", {
err: summarizeError(err),
sessionKeyHash: hashSessionKey(sessionKey),
});
}
return false;
}
}