fix: route subagent announce to originating parent session instead of channel-bound peer session (#80242)

* fix: route subagent announce to originating parent session instead of channel-bound peer session

When a subagent is spawned from agent:main:main while a Telegram DM is active,
the completion announce was delivered to the parallel Telegram channel session
instead of the originating parent.

Two interacting bugs:

1. The spawn tool received the sandbox/policy session key (Telegram peer key)
   as the requester, instead of the real run session key. Fixed by passing
   runSessionKey to createSessionsSpawnTool so the registered requester
   points to the actual parent session.

2. resolveSubagentCompletionOrigin checked child session bindings before
   requester bindings. When both share the same channel+accountId (common
   for Telegram DMs), the child binding hijacked the delivery target.
   Fixed by checking requester binding first, with child as fallback.

Fixes #80201

* fix: drop subagent_announce from mediated completion set

The subagent_announce addition to AGENT_MEDIATED_COMPLETION_TOOLS was
unrelated to the routing fix and could cause group/channel completions
to fail silently when the subagent does not use the message tool.

This should be addressed separately with proper message-tool-only
guidance (tracked in #80223).

* fix: separate sandbox policy from completion owner in sessions_spawn

PR #80242 passed runSessionKey as agentSessionKey to createSessionsSpawnTool,
which caused spawnSubagentDirect to use the run session key for sandbox policy
checks (resolveSandboxRuntimeStatus). This could make a sandboxed channel run
appear unsandboxed.

Introduce completionOwnerKey as a separate field that is only used for
registerSubagentRun routing (requesterSessionKey), keeping agentSessionKey
for sandbox enforcement, callerDepth, activeChildren, and all other policy
checks.

* fix(agents): preserve subagent ownership routing

---------

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jerry-Xin
2026-05-17 12:17:51 +01:00
committed by GitHub
co-authored by 忻役 Peter Steinberger
parent d1cd74b243
commit 3e9e1d6321
10 changed files with 343 additions and 45 deletions
+1
View File
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
- Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang.
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
+1
View File
@@ -428,6 +428,7 @@ export function createOpenClawTools(
? [
createSessionsSpawnTool({
agentSessionKey: options?.agentSessionKey,
completionOwnerKey: options?.runSessionKey,
agentChannel: options?.agentChannel,
agentAccountId: options?.agentAccountId,
agentTo: options?.agentTo,
@@ -436,6 +436,112 @@ describe("resolveSubagentCompletionOrigin", () => {
to: "channel:parent-main",
});
});
it("prefers requester binding when child and requester share the same channel and accountId", async () => {
registerSessionBindingAdapter({
channel: "telegram",
accountId: "bot-1",
listBySession: (targetSessionKey: string) => {
if (targetSessionKey === "agent:main:telegram:default:direct:123") {
return [
{
bindingId: "telegram:bot-1:child-dm",
targetSessionKey,
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "bot-1",
conversationId: "direct:123",
},
status: "active",
boundAt: 1,
},
];
}
if (targetSessionKey === "agent:main:main") {
return [
{
bindingId: "telegram:bot-1:parent-main",
targetSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "bot-1",
conversationId: "direct:789",
},
status: "active",
boundAt: 1,
},
];
}
return [];
},
resolveByConversation: () => null,
});
const origin = await resolveSubagentCompletionOrigin({
childSessionKey: "agent:main:telegram:default:direct:123",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "telegram",
accountId: "bot-1",
to: "telegram:direct:789",
},
spawnMode: "run",
expectsCompletionMessage: true,
});
expect(origin).toEqual({
channel: "telegram",
accountId: "bot-1",
to: "telegram:direct:789",
});
});
it("falls back to child binding when requester has no binding", async () => {
registerSessionBindingAdapter({
channel: "telegram",
accountId: "bot-1",
listBySession: (targetSessionKey: string) => {
if (targetSessionKey === "agent:main:telegram:default:direct:123") {
return [
{
bindingId: "telegram:bot-1:child-dm",
targetSessionKey,
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "bot-1",
conversationId: "direct:123",
},
status: "active",
boundAt: 1,
},
];
}
return [];
},
resolveByConversation: () => null,
});
const origin = await resolveSubagentCompletionOrigin({
childSessionKey: "agent:main:telegram:default:direct:123",
requesterSessionKey: "agent:main:main",
requesterOrigin: {
channel: "telegram",
accountId: "bot-1",
to: "telegram:direct:123",
},
spawnMode: "run",
expectsCompletionMessage: true,
});
expect(origin).toEqual({
channel: "telegram",
accountId: "bot-1",
to: "telegram:direct:123",
});
});
});
describe("deliverSubagentAnnouncement active requester steering", () => {
+17 -17
View File
@@ -363,6 +363,23 @@ export async function resolveSubagentCompletionOrigin(params: {
channel && conversationId ? { channel, accountId, conversationId } : undefined;
const router = createBoundDeliveryRouter();
const requesterRoute = router.resolveDestination({
eventKind: "task_completion",
targetSessionKey: params.requesterSessionKey,
requester: requesterConversation,
failClosed: true,
});
if (requesterRoute.mode === "bound" && requesterRoute.binding) {
return mergeDeliveryContext(
resolveBoundConversationOrigin({
bindingConversation: requesterRoute.binding.conversation,
requesterConversation,
requesterOrigin,
}),
requesterOrigin,
);
}
const childRoute = router.resolveDestination({
eventKind: "task_completion",
targetSessionKey: params.childSessionKey,
@@ -380,23 +397,6 @@ export async function resolveSubagentCompletionOrigin(params: {
);
}
const route = router.resolveDestination({
eventKind: "task_completion",
targetSessionKey: params.requesterSessionKey,
requester: requesterConversation,
failClosed: true,
});
if (route.mode === "bound" && route.binding) {
return mergeDeliveryContext(
resolveBoundConversationOrigin({
bindingConversation: route.binding.conversation,
requesterConversation,
requesterOrigin,
}),
requesterOrigin,
);
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("subagent_delivery_target")) {
return requesterOrigin;
+48
View File
@@ -0,0 +1,48 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./tools/sessions-helpers.js";
export type SubagentSpawnOwnership = {
controllerSessionKey: string;
threadBindingRequesterSessionKey: string;
completionRequesterSessionKey: string;
completionRequesterDisplayKey: string;
};
export function resolveSubagentSpawnOwnership(params: {
cfg: OpenClawConfig;
agentSessionKey?: string;
completionOwnerKey?: string;
}): SubagentSpawnOwnership {
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
const controllerSessionKey = params.agentSessionKey
? resolveInternalSessionKey({
key: params.agentSessionKey,
alias,
mainKey,
})
: alias;
const completionOwnerKey = params.completionOwnerKey?.trim();
const completionRequesterSessionKey = completionOwnerKey
? resolveInternalSessionKey({
key: completionOwnerKey,
alias,
mainKey,
})
: controllerSessionKey;
const completionRequesterDisplayKey = resolveDisplaySessionKey({
key: completionRequesterSessionKey,
alias,
mainKey,
});
return {
controllerSessionKey,
threadBindingRequesterSessionKey: controllerSessionKey,
completionRequesterSessionKey,
completionRequesterDisplayKey,
};
}
+20
View File
@@ -259,6 +259,26 @@ describe("spawnSubagentDirect seam flow", () => {
expect(agentParams.cleanupBundleMcpOnRunEnd).toBe(true);
});
it("keeps controller ownership separate from completion ownership", async () => {
await spawnSubagentDirect(
{
task: "background work",
},
{
agentSessionKey: "agent:main:telegram:default:direct:456",
completionOwnerKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
},
);
const registerInput = firstRegisteredSubagentRun();
expect(registerInput.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(registerInput.requesterSessionKey).toBe("agent:main:main");
expect(registerInput.requesterDisplayKey).toBe("agent:main:main");
});
it("omits requesterOrigin threadId when no requester thread is provided", async () => {
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent") {
@@ -19,12 +19,18 @@ const hoisted = vi.hoisted(() => ({
}));
function firstRegisteredSubagentRun(): {
controllerSessionKey?: string;
requesterSessionKey?: string;
requesterDisplayKey?: string;
requesterOrigin?: { channel?: string; accountId?: string; to?: string };
expectsCompletionMessage?: boolean;
spawnMode?: string;
} {
const call = hoisted.registerSubagentRunMock.mock.calls[0]?.[0] as
| {
controllerSessionKey?: string;
requesterSessionKey?: string;
requesterDisplayKey?: string;
requesterOrigin?: { channel?: string; accountId?: string; to?: string };
expectsCompletionMessage?: boolean;
spawnMode?: string;
@@ -187,6 +193,45 @@ describe("spawnSubagentDirect thread binding delivery", () => {
expect(registeredRun?.spawnMode).toBe("session");
});
it("uses controller ownership for thread binding while completion routes to owner", async () => {
let hookRequesterSessionKey: string | undefined;
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockImplementation(
async (_event: unknown, ctx?: { requesterSessionKey?: string }) => {
hookRequesterSessionKey = ctx?.requesterSessionKey;
return {
status: "ok",
threadBindingReady: true,
};
},
);
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
thread: true,
mode: "session",
context: "isolated",
},
{
agentSessionKey: "agent:main:telegram:default:direct:456",
completionOwnerKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
},
);
expect(result.status).toBe("accepted");
expect(hookRequesterSessionKey).toBe("agent:main:telegram:default:direct:456");
const registeredRun = firstRegisteredSubagentRun();
expect(registeredRun.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(registeredRun.requesterSessionKey).toBe("agent:main:main");
expect(registeredRun.requesterDisplayKey).toBe("agent:main:main");
});
it("keeps completion announcements when only a generic binding is available", async () => {
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
+11 -9
View File
@@ -35,6 +35,7 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js";
import { resolveSubagentSpawnOwnership } from "./subagent-spawn-ownership.js";
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
import { normalizeSubagentTaskName } from "./subagent-task-name.js";
export {
@@ -66,7 +67,6 @@ import {
resolveParentForkDecision,
resolveAgentConfig,
resolveContextEngine,
resolveDisplaySessionKey,
resolveGatewaySessionStoreTarget,
resolveInternalSessionKey,
resolveMainSessionAlias,
@@ -147,6 +147,8 @@ export type SpawnSubagentParams = {
export type SpawnSubagentContext = {
agentSessionKey?: string;
/** Separate key used only for completion routing, not sandbox policy. */
completionOwnerKey?: string;
agentChannel?: string;
agentAccountId?: string;
agentTo?: string;
@@ -769,10 +771,10 @@ export async function spawnSubagentDirect(
mainKey,
})
: alias;
const requesterDisplayKey = resolveDisplaySessionKey({
key: requesterInternalKey,
alias,
mainKey,
const ownership = resolveSubagentSpawnOwnership({
cfg,
agentSessionKey: ctx.agentSessionKey,
completionOwnerKey: ctx.completionOwnerKey,
});
const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg });
@@ -980,7 +982,7 @@ export async function spawnSubagentDirect(
agentId: targetAgentId,
label: label || undefined,
mode: spawnMode,
requesterSessionKey: requesterInternalKey,
requesterSessionKey: ownership.threadBindingRequesterSessionKey,
requester: {
channel: childSessionOrigin?.channel,
accountId: childSessionOrigin?.accountId,
@@ -1245,10 +1247,10 @@ export async function spawnSubagentDirect(
registerSubagentRun({
runId: childRunId,
childSessionKey,
controllerSessionKey: requesterInternalKey,
requesterSessionKey: requesterInternalKey,
controllerSessionKey: ownership.controllerSessionKey,
requesterSessionKey: ownership.completionRequesterSessionKey,
requesterOrigin,
requesterDisplayKey,
requesterDisplayKey: ownership.completionRequesterDisplayKey,
task,
taskName,
cleanup,
@@ -902,4 +902,87 @@ describe("sessions_spawn tool", () => {
expect(contentSchema?.type).toBe("string");
expect(contentSchema?.maxLength).toBeUndefined();
});
it("registers requesterSessionKey from the provided agentSessionKey, not the sandbox peer key", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "bot-1",
agentTo: "telegram:direct:123",
});
await tool.execute("call-requester-key", {
task: "background research",
});
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:main");
});
it("does not use the Telegram peer key as requesterSessionKey when agentSessionKey is the run session", async () => {
const telegramPeerKey = "agent:main:telegram:default:direct:456";
const runSessionKey = "agent:main:main";
const toolWithPeerKey = createSessionsSpawnTool({
agentSessionKey: telegramPeerKey,
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
});
await toolWithPeerKey.execute("call-peer-key", { task: "task A" });
const toolWithRunKey = createSessionsSpawnTool({
agentSessionKey: runSessionKey,
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
});
await toolWithRunKey.execute("call-run-key", { task: "task B" });
const peerContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
const runContext = mockCallArg(hoisted.spawnSubagentDirectMock, 1, 1, "spawnSubagentDirect");
expect(peerContext.agentSessionKey).toBe(telegramPeerKey);
expect(runContext.agentSessionKey).toBe(runSessionKey);
});
it("passes completionOwnerKey through to spawnSubagentDirect separately from agentSessionKey", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:telegram:default:direct:456",
completionOwnerKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
});
await tool.execute("call-completion-owner", { task: "background work" });
const spawnContext = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 1, "spawnSubagentDirect");
expect(spawnContext.agentSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(spawnContext.completionOwnerKey).toBe("agent:main:main");
});
it("uses completionOwnerKey for ACP registerSubagentRun requesterSessionKey", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:telegram:default:direct:456",
completionOwnerKey: "agent:main:main",
agentChannel: "telegram",
agentAccountId: "default",
agentTo: "telegram:direct:456",
});
await tool.execute("call-acp-completion-owner", {
runtime: "acp",
task: "investigate",
agentId: "codex",
});
const registration = mockCallArg(hoisted.registerSubagentRunMock, 0, 0, "registerSubagentRun");
expect(registration.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(registration.requesterSessionKey).toBe("agent:main:main");
expect(registration.requesterDisplayKey).toBe("agent:main:main");
});
});
+11 -19
View File
@@ -19,6 +19,7 @@ import {
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";
import { registerSubagentRun } from "../subagent-registry.js";
import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js";
import {
SUBAGENT_SPAWN_CONTEXT_MODES,
SUBAGENT_SPAWN_MODES,
@@ -37,11 +38,6 @@ import {
readStringParam,
ToolInputError,
} from "./common.js";
import {
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./sessions-helpers.js";
const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const;
const SESSIONS_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const;
@@ -250,6 +246,8 @@ function resolveAcpUnavailableMessage(opts?: { sandboxed?: boolean; config?: Ope
export function createSessionsSpawnTool(
opts?: {
agentSessionKey?: string;
/** Separate key used only for completion routing (registerSubagentRun requesterSessionKey). */
completionOwnerKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
agentTo?: string;
@@ -419,18 +417,10 @@ export function createSessionsSpawnTool(
threadRequested: thread,
});
const trackedCleanup = trackedSpawnMode === "session" ? "keep" : cleanup;
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterInternalKey = opts?.agentSessionKey
? resolveInternalSessionKey({
key: opts.agentSessionKey,
alias,
mainKey,
})
: alias;
const requesterDisplayKey = resolveDisplaySessionKey({
key: requesterInternalKey,
alias,
mainKey,
const ownership = resolveSubagentSpawnOwnership({
cfg,
agentSessionKey: opts?.agentSessionKey,
completionOwnerKey: opts?.completionOwnerKey,
});
const requesterOrigin = normalizeDeliveryContext({
channel: opts?.agentChannel,
@@ -445,9 +435,10 @@ export function createSessionsSpawnTool(
registerSubagentRun({
runId: childRunId,
childSessionKey,
requesterSessionKey: requesterInternalKey,
controllerSessionKey: ownership.controllerSessionKey,
requesterSessionKey: ownership.completionRequesterSessionKey,
requesterOrigin,
requesterDisplayKey,
requesterDisplayKey: ownership.completionRequesterDisplayKey,
task,
taskName,
cleanup: trackedCleanup,
@@ -497,6 +488,7 @@ export function createSessionsSpawnTool(
},
{
agentSessionKey: opts?.agentSessionKey,
completionOwnerKey: opts?.completionOwnerKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,