Skip to content

Commit 2c57d70

Browse files
sfuminyasteipete
andauthored
fix: preserve requester route for subagent completion delivery (#72806)
* fix: preserve requester route for subagent completion delivery * fix(agents): preserve requester subagent completion routes --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent dfd9dbe commit 2c57d70

5 files changed

Lines changed: 114 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535
- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe.
3636
- Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:<id>`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.
3737
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
38+
- Agents/subagents: preserve requester delivery for completion announces when a child agent is bound to a different channel account while keeping same-channel thread completions routed to the child thread. Thanks @sfuminya.
3839
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
3940
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.
4041
- Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels.

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
__testing as sessionBindingServiceTesting,
4+
registerSessionBindingAdapter,
5+
} from "../infra/outbound/session-binding-service.js";
26
import type { AgentInternalEvent } from "./internal-events.js";
37
import {
48
__testing,
59
deliverSubagentAnnouncement,
610
extractThreadCompletionFallbackText,
11+
resolveSubagentCompletionOrigin,
712
} from "./subagent-announce-delivery.js";
813
import {
914
callGateway as runtimeCallGateway,
@@ -14,6 +19,7 @@ import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
1419

1520
afterEach(() => {
1621
resetAnnounceQueuesForTests();
22+
sessionBindingServiceTesting.resetSessionBindingAdaptersForTests();
1723
__testing.setDepsForTest();
1824
});
1925

@@ -270,6 +276,77 @@ describe("resolveAnnounceOrigin threaded route targets", () => {
270276
});
271277
});
272278

279+
describe("resolveSubagentCompletionOrigin", () => {
280+
it("resolves bound completion delivery from the requester session, not the child session", async () => {
281+
registerSessionBindingAdapter({
282+
channel: "discord",
283+
accountId: "bot-alpha",
284+
listBySession: (targetSessionKey: string) => {
285+
if (targetSessionKey === "agent:worker:subagent:child") {
286+
return [
287+
{
288+
bindingId: "discord:bot-alpha:child-window",
289+
targetSessionKey,
290+
targetKind: "subagent",
291+
conversation: {
292+
channel: "discord",
293+
accountId: "bot-alpha",
294+
conversationId: "child-window",
295+
},
296+
status: "active",
297+
boundAt: 1,
298+
},
299+
];
300+
}
301+
return [];
302+
},
303+
resolveByConversation: () => null,
304+
});
305+
registerSessionBindingAdapter({
306+
channel: "discord",
307+
accountId: "acct-1",
308+
listBySession: (targetSessionKey: string) => {
309+
if (targetSessionKey === "agent:main:main") {
310+
return [
311+
{
312+
bindingId: "discord:acct-1:parent-main",
313+
targetSessionKey,
314+
targetKind: "session",
315+
conversation: {
316+
channel: "discord",
317+
accountId: "acct-1",
318+
conversationId: "parent-main",
319+
},
320+
status: "active",
321+
boundAt: 1,
322+
},
323+
];
324+
}
325+
return [];
326+
},
327+
resolveByConversation: () => null,
328+
});
329+
330+
const origin = await resolveSubagentCompletionOrigin({
331+
childSessionKey: "agent:worker:subagent:child",
332+
requesterSessionKey: "agent:main:main",
333+
requesterOrigin: {
334+
channel: "discord",
335+
accountId: "acct-1",
336+
to: "channel:parent-main",
337+
},
338+
spawnMode: "session",
339+
expectsCompletionMessage: true,
340+
});
341+
342+
expect(origin).toEqual({
343+
channel: "discord",
344+
accountId: "acct-1",
345+
to: "channel:parent-main",
346+
});
347+
});
348+
});
349+
273350
describe("deliverSubagentAnnouncement queued delivery", () => {
274351
async function deliverQueuedAnnouncement(params: {
275352
requesterOrigin?: {

src/agents/subagent-announce-delivery.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,11 +306,29 @@ export async function resolveSubagentCompletionOrigin(params: {
306306
const requesterConversation: ConversationRef | undefined =
307307
channel && conversationId ? { channel, accountId, conversationId } : undefined;
308308

309-
const route = createBoundDeliveryRouter().resolveDestination({
309+
const router = createBoundDeliveryRouter();
310+
const childRoute = router.resolveDestination({
310311
eventKind: "task_completion",
311312
targetSessionKey: params.childSessionKey,
312313
requester: requesterConversation,
313-
failClosed: false,
314+
failClosed: true,
315+
});
316+
if (childRoute.mode === "bound" && childRoute.binding) {
317+
return mergeDeliveryContext(
318+
resolveBoundConversationOrigin({
319+
bindingConversation: childRoute.binding.conversation,
320+
requesterConversation,
321+
requesterOrigin,
322+
}),
323+
requesterOrigin,
324+
);
325+
}
326+
327+
const route = router.resolveDestination({
328+
eventKind: "task_completion",
329+
targetSessionKey: params.requesterSessionKey,
330+
requester: requesterConversation,
331+
failClosed: true,
314332
});
315333
if (route.mode === "bound" && route.binding) {
316334
return mergeDeliveryContext(

src/agents/subagent-spawn.thread-binding.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,8 @@ describe("spawnSubagentDirect thread binding delivery", () => {
163163
expect.objectContaining({
164164
requesterOrigin: {
165165
channel: "matrix",
166-
accountId: "bot-alpha",
166+
accountId: "bot-beta",
167167
to: `room:${boundRoom}`,
168-
threadId: "$thread-root",
169168
},
170169
expectsCompletionMessage: false,
171170
spawnMode: "session",

src/agents/subagent-spawn.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,15 @@ export async function spawnSubagentDirect(
725725
};
726726
}
727727
const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId;
728-
const requesterOrigin = resolveRequesterOriginForChild({
728+
const requesterOrigin = normalizeDeliveryContext({
729+
channel: ctx.agentChannel,
730+
accountId: ctx.agentAccountId,
731+
to: ctx.agentTo,
732+
...(ctx.agentThreadId != null && ctx.agentThreadId !== ""
733+
? { threadId: ctx.agentThreadId }
734+
: {}),
735+
});
736+
let childSessionOrigin = resolveRequesterOriginForChild({
729737
cfg,
730738
targetAgentId,
731739
requesterAgentId,
@@ -736,7 +744,6 @@ export async function spawnSubagentDirect(
736744
requesterGroupSpace: ctx.agentGroupSpace,
737745
requesterMemberRoleIds: ctx.agentMemberRoleIds,
738746
});
739-
let childSessionOrigin = requesterOrigin;
740747
if (targetAgentId !== requesterAgentId) {
741748
const allowAgents =
742749
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
@@ -892,10 +899,10 @@ export async function spawnSubagentDirect(
892899
mode: spawnMode,
893900
requesterSessionKey: requesterInternalKey,
894901
requester: {
895-
channel: requesterOrigin?.channel,
896-
accountId: requesterOrigin?.accountId,
897-
to: requesterOrigin?.to,
898-
threadId: requesterOrigin?.threadId,
902+
channel: childSessionOrigin?.channel,
903+
accountId: childSessionOrigin?.accountId,
904+
to: childSessionOrigin?.to,
905+
threadId: childSessionOrigin?.threadId,
899906
},
900907
});
901908
if (bindResult.status === "error") {
@@ -917,7 +924,7 @@ export async function spawnSubagentDirect(
917924
threadBindingReady = true;
918925
hasBoundThreadDeliveryOrigin = hasRoutableDeliveryOrigin(bindResult.deliveryOrigin);
919926
childSessionOrigin =
920-
mergeDeliveryContext(bindResult.deliveryOrigin, requesterOrigin) ?? childSessionOrigin;
927+
mergeDeliveryContext(bindResult.deliveryOrigin, childSessionOrigin) ?? childSessionOrigin;
921928
}
922929
const mountPathHint = sanitizeMountPathHint(params.attachMountPath);
923930

@@ -1152,7 +1159,7 @@ export async function spawnSubagentDirect(
11521159
childSessionKey,
11531160
controllerSessionKey: requesterInternalKey,
11541161
requesterSessionKey: requesterInternalKey,
1155-
requesterOrigin: childSessionOrigin,
1162+
requesterOrigin,
11561163
requesterDisplayKey,
11571164
task,
11581165
cleanup,

0 commit comments

Comments
 (0)