mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
src/infra/outbound/pending-spawn-query.ts
Normal file
57
src/infra/outbound/pending-spawn-query.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user