diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b8c8fc749..91c1d61d7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 6490faf516f..932a71fe335 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -167,6 +167,10 @@ Defaults live under `agents.defaults.silentReply` and `agents.defaults.silentReplyRewrite`; `surfaces..silentReply` and `surfaces..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 diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 9d6668454bc..a21be5b4266 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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; +}); diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index eeefec8377a..2f603cd97c9 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -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: { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index e413aa82ace..5b0569f92de 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -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) { diff --git a/src/infra/outbound/pending-spawn-query.ts b/src/infra/outbound/pending-spawn-query.ts new file mode 100644 index 00000000000..fff186e9a40 --- /dev/null +++ b/src/infra/outbound/pending-spawn-query.ts @@ -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; + } +}