Skip to content

Commit 4da808d

Browse files
stainluobviyus
andauthored
fix: scope nested agent lanes per target session (#67785) (thanks @stainlu)
* fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 67bd9ed commit 4da808d

10 files changed

Lines changed: 119 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
### Fixes
1010

1111
- Agents/openai-completions: always send `stream_options.include_usage` on streaming requests, so local and custom OpenAI-compatible backends report real context usage instead of showing 0%. (#68746) Thanks @kagura-agent.
12+
- Agents/nested lanes: scope nested agent work per target session so a long-running nested run on one session no longer head-of-line blocks unrelated sessions across the gateway. (#67785) Thanks @stainlu.
1213

1314
## 2026.4.19-beta.1
1415

src/agents/command/delivery.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import type { OutboundSessionContext } from "../../infra/outbound/session-context.js";
2626
import type { RuntimeEnv } from "../../runtime.js";
2727
import { isInternalMessageChannel } from "../../utils/message-channel.js";
28-
import { AGENT_LANE_NESTED } from "../lanes.js";
28+
import { isNestedAgentLane } from "../lanes.js";
2929
import type { AgentCommandOpts } from "./types.js";
3030

3131
type RunResult = Awaited<ReturnType<(typeof import("../pi-embedded.js"))["runEmbeddedPiAgent"]>>;
@@ -351,7 +351,7 @@ export async function deliverAgentCommandResult(params: {
351351
if (!output) {
352352
return;
353353
}
354-
if (opts.lane === AGENT_LANE_NESTED) {
354+
if (isNestedAgentLane(opts.lane)) {
355355
logNestedOutput(runtime, opts, output, effectiveSessionKey);
356356
return;
357357
}

src/agents/lanes.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "vitest";
2-
import { AGENT_LANE_NESTED, resolveNestedAgentLane } from "./lanes.js";
2+
import {
3+
AGENT_LANE_NESTED,
4+
isNestedAgentLane,
5+
resolveNestedAgentLane,
6+
resolveNestedAgentLaneForSession,
7+
} from "./lanes.js";
38

49
describe("resolveNestedAgentLane", () => {
510
it("defaults to the nested lane when no lane is provided", () => {
@@ -16,3 +21,63 @@ describe("resolveNestedAgentLane", () => {
1621
expect(resolveNestedAgentLane(" custom-lane ")).toBe("custom-lane");
1722
});
1823
});
24+
25+
describe("resolveNestedAgentLaneForSession (#67502)", () => {
26+
it("falls back to the unscoped nested lane when no session key is provided", () => {
27+
expect(resolveNestedAgentLaneForSession(undefined)).toBe(AGENT_LANE_NESTED);
28+
expect(resolveNestedAgentLaneForSession("")).toBe(AGENT_LANE_NESTED);
29+
expect(resolveNestedAgentLaneForSession(" ")).toBe(AGENT_LANE_NESTED);
30+
});
31+
32+
it("scopes the nested lane per target session key", () => {
33+
expect(resolveNestedAgentLaneForSession("agent:ebao-next:discord:channel:1")).toBe(
34+
`${AGENT_LANE_NESTED}:agent:ebao-next:discord:channel:1`,
35+
);
36+
});
37+
38+
it("produces distinct lanes for distinct target sessions", () => {
39+
const laneA = resolveNestedAgentLaneForSession("agent:ebao-next:discord:channel:1");
40+
const laneB = resolveNestedAgentLaneForSession("agent:ebao-vue:discord:channel:2");
41+
expect(laneA).not.toBe(laneB);
42+
});
43+
44+
it("is deterministic for the same session key across calls", () => {
45+
const key = "agent:ebao:discord:channel:1";
46+
expect(resolveNestedAgentLaneForSession(key)).toBe(resolveNestedAgentLaneForSession(key));
47+
});
48+
49+
it("trims whitespace around the session key before scoping", () => {
50+
expect(resolveNestedAgentLaneForSession(" agent:ebao:main ")).toBe(
51+
`${AGENT_LANE_NESTED}:agent:ebao:main`,
52+
);
53+
});
54+
});
55+
56+
describe("isNestedAgentLane", () => {
57+
it("returns true for the unscoped nested lane", () => {
58+
expect(isNestedAgentLane(AGENT_LANE_NESTED)).toBe(true);
59+
});
60+
61+
it("returns true for per-session nested lanes", () => {
62+
expect(isNestedAgentLane(resolveNestedAgentLaneForSession("agent:a:main"))).toBe(true);
63+
expect(isNestedAgentLane(`${AGENT_LANE_NESTED}:agent:a:main`)).toBe(true);
64+
});
65+
66+
it("returns false for unrelated lanes", () => {
67+
expect(isNestedAgentLane("main")).toBe(false);
68+
expect(isNestedAgentLane("cron")).toBe(false);
69+
expect(isNestedAgentLane("subagent")).toBe(false);
70+
expect(isNestedAgentLane("session:agent:a:main")).toBe(false);
71+
});
72+
73+
it("returns false for lanes that merely contain 'nested' as a substring", () => {
74+
expect(isNestedAgentLane("deeply-nested-lane")).toBe(false);
75+
expect(isNestedAgentLane("session:nested")).toBe(false);
76+
expect(isNestedAgentLane("nestedfoo")).toBe(false);
77+
});
78+
79+
it("returns false for empty or missing lane names", () => {
80+
expect(isNestedAgentLane(undefined)).toBe(false);
81+
expect(isNestedAgentLane("")).toBe(false);
82+
});
83+
});

src/agents/lanes.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { CommandLane } from "../process/lanes.js";
22

33
export const AGENT_LANE_NESTED = CommandLane.Nested;
44
export const AGENT_LANE_SUBAGENT = CommandLane.Subagent;
5+
const NESTED_LANE = "nested";
6+
const NESTED_LANE_PREFIX = `${NESTED_LANE}:`;
57

68
export function resolveNestedAgentLane(lane?: string): string {
79
const trimmed = lane?.trim();
@@ -12,3 +14,18 @@ export function resolveNestedAgentLane(lane?: string): string {
1214
}
1315
return trimmed;
1416
}
17+
18+
export function resolveNestedAgentLaneForSession(sessionKey: string | undefined): string {
19+
const trimmed = sessionKey?.trim();
20+
if (!trimmed) {
21+
return AGENT_LANE_NESTED;
22+
}
23+
return `${NESTED_LANE_PREFIX}${trimmed}`;
24+
}
25+
26+
export function isNestedAgentLane(lane: string | undefined): boolean {
27+
if (!lane) {
28+
return false;
29+
}
30+
return lane === NESTED_LANE || lane.startsWith(NESTED_LANE_PREFIX);
31+
}

src/agents/openclaw-tools.sessions.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ describe("sessions tools", () => {
758758
expect(agentCalls).toHaveLength(8);
759759
for (const call of agentCalls) {
760760
expect(call.params).toMatchObject({
761-
lane: "nested",
761+
lane: expect.stringMatching(/^nested(?::|$)/),
762762
channel: "webchat",
763763
inputProvenance: { kind: "inter_session" },
764764
});
@@ -938,7 +938,7 @@ describe("sessions tools", () => {
938938
expect(agentCalls).toHaveLength(4);
939939
for (const call of agentCalls) {
940940
expect(call.params).toMatchObject({
941-
lane: "nested",
941+
lane: expect.stringMatching(/^nested(?::|$)/),
942942
channel: "webchat",
943943
inputProvenance: { kind: "inter_session" },
944944
});

src/agents/tools/agent-step.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import crypto from "node:crypto";
22
import { callGateway } from "../../gateway/call.js";
33
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
4-
import { AGENT_LANE_NESTED } from "../lanes.js";
4+
import { resolveNestedAgentLaneForSession } from "../lanes.js";
55
import { waitForAgentRunAndReadUpdatedAssistantReply } from "../run-wait.js";
66

77
export { readLatestAssistantReply } from "../run-wait.js";
@@ -36,7 +36,7 @@ export async function runAgentStep(params: {
3636
idempotencyKey: stepIdem,
3737
deliver: false,
3838
channel: params.channel ?? INTERNAL_MESSAGE_CHANNEL,
39-
lane: params.lane ?? AGENT_LANE_NESTED,
39+
lane: params.lane ?? resolveNestedAgentLaneForSession(params.sessionKey),
4040
extraSystemPrompt: params.extraSystemPrompt,
4141
inputProvenance: {
4242
kind: "inter_session",

src/agents/tools/sessions-send-tool.a2a.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { CallGatewayOptions } from "../../gateway/call.js";
33
import { formatErrorMessage } from "../../infra/errors.js";
44
import { createSubsystemLogger } from "../../logging/subsystem.js";
55
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
6-
import { AGENT_LANE_NESTED } from "../lanes.js";
6+
import { resolveNestedAgentLaneForSession } from "../lanes.js";
77
import { readLatestAssistantReply, waitForAgentRun } from "../run-wait.js";
88
import { runAgentStep } from "./agent-step.js";
99
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
@@ -92,7 +92,7 @@ export async function runSessionsSendA2AFlow(params: {
9292
message: incomingMessage,
9393
extraSystemPrompt: replyPrompt,
9494
timeoutMs: params.announceTimeoutMs,
95-
lane: AGENT_LANE_NESTED,
95+
lane: resolveNestedAgentLaneForSession(currentSessionKey),
9696
sourceSessionKey: nextSessionKey,
9797
sourceChannel:
9898
nextSessionKey === params.requesterSessionKey ? params.requesterChannel : targetChannel,
@@ -123,7 +123,7 @@ export async function runSessionsSendA2AFlow(params: {
123123
message: "Agent-to-agent announce step.",
124124
extraSystemPrompt: announcePrompt,
125125
timeoutMs: params.announceTimeoutMs,
126-
lane: AGENT_LANE_NESTED,
126+
lane: resolveNestedAgentLaneForSession(params.targetSessionKey),
127127
sourceSessionKey: params.requesterSessionKey,
128128
sourceChannel: params.requesterChannel,
129129
sourceTool: "sessions_send",

src/agents/tools/sessions-send-tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type GatewayMessageChannel,
1111
INTERNAL_MESSAGE_CHANNEL,
1212
} from "../../utils/message-channel.js";
13-
import { AGENT_LANE_NESTED } from "../lanes.js";
13+
import { resolveNestedAgentLaneForSession } from "../lanes.js";
1414
import {
1515
readLatestAssistantReplySnapshot,
1616
waitForAgentRunAndReadUpdatedAssistantReply,
@@ -276,7 +276,7 @@ export function createSessionsSendTool(opts?: {
276276
idempotencyKey,
277277
deliver: false,
278278
channel: INTERNAL_MESSAGE_CHANNEL,
279-
lane: AGENT_LANE_NESTED,
279+
lane: resolveNestedAgentLaneForSession(resolvedKey),
280280
extraSystemPrompt: agentMessageContext,
281281
inputProvenance: {
282282
kind: "inter_session",

src/commands/agent.delivery.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,29 @@ describe("deliverAgentCommandResult", () => {
292292
expect(line).toContain("ANNOUNCE_SKIP");
293293
});
294294

295+
it("prefixes per-session nested lanes with the same nested log context (#67502)", async () => {
296+
const runtime = createRuntime();
297+
await runDelivery({
298+
runtime,
299+
resultText: "ANNOUNCE_SKIP",
300+
opts: {
301+
message: "hello",
302+
deliver: false,
303+
lane: "nested:agent:ebao-next:discord:channel:1",
304+
sessionKey: "agent:ebao-next:discord:channel:1",
305+
runId: "run-announce",
306+
messageChannel: "webchat",
307+
},
308+
sessionEntry: undefined,
309+
});
310+
311+
expect(runtime.log).toHaveBeenCalledTimes(1);
312+
const line = String((runtime.log as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]);
313+
expect(line).toContain("[agent:nested]");
314+
expect(line).toContain("session=agent:ebao-next:discord:channel:1");
315+
expect(line).toContain("ANNOUNCE_SKIP");
316+
});
317+
295318
it("preserves audioAsVoice in JSON output envelopes", async () => {
296319
const runtime = createRuntime();
297320
await runDelivery({

src/gateway/server.sessions-send.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe("sessions_send gateway loopback", () => {
150150
const firstCall = spy.mock.calls[0]?.[0] as
151151
| { lane?: string; inputProvenance?: { kind?: string; sourceTool?: string } }
152152
| undefined;
153-
expect(firstCall?.lane).toBe("nested");
153+
expect(firstCall?.lane).toMatch(/^nested(?::|$)/);
154154
expect(firstCall?.inputProvenance).toMatchObject({
155155
kind: "inter_session",
156156
sourceTool: "sessions_send",

0 commit comments

Comments
 (0)