diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c7ec96e55..f46d8d439c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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. +- 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. ## 2026.4.19-beta.1 diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index 46ab9ec8d24..35bff88bfdb 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -25,7 +25,7 @@ import { import type { OutboundSessionContext } from "../../infra/outbound/session-context.js"; import type { RuntimeEnv } from "../../runtime.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; -import { AGENT_LANE_NESTED } from "../lanes.js"; +import { isNestedAgentLane } from "../lanes.js"; import type { AgentCommandOpts } from "./types.js"; type RunResult = Awaited>; @@ -351,7 +351,7 @@ export async function deliverAgentCommandResult(params: { if (!output) { return; } - if (opts.lane === AGENT_LANE_NESTED) { + if (isNestedAgentLane(opts.lane)) { logNestedOutput(runtime, opts, output, effectiveSessionKey); return; } diff --git a/src/agents/lanes.test.ts b/src/agents/lanes.test.ts index 9538de70d26..0e0db437b53 100644 --- a/src/agents/lanes.test.ts +++ b/src/agents/lanes.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { AGENT_LANE_NESTED, resolveNestedAgentLane } from "./lanes.js"; +import { + AGENT_LANE_NESTED, + isNestedAgentLane, + resolveNestedAgentLane, + resolveNestedAgentLaneForSession, +} from "./lanes.js"; describe("resolveNestedAgentLane", () => { it("defaults to the nested lane when no lane is provided", () => { @@ -16,3 +21,63 @@ describe("resolveNestedAgentLane", () => { expect(resolveNestedAgentLane(" custom-lane ")).toBe("custom-lane"); }); }); + +describe("resolveNestedAgentLaneForSession (#67502)", () => { + it("falls back to the unscoped nested lane when no session key is provided", () => { + expect(resolveNestedAgentLaneForSession(undefined)).toBe(AGENT_LANE_NESTED); + expect(resolveNestedAgentLaneForSession("")).toBe(AGENT_LANE_NESTED); + expect(resolveNestedAgentLaneForSession(" ")).toBe(AGENT_LANE_NESTED); + }); + + it("scopes the nested lane per target session key", () => { + expect(resolveNestedAgentLaneForSession("agent:ebao-next:discord:channel:1")).toBe( + `${AGENT_LANE_NESTED}:agent:ebao-next:discord:channel:1`, + ); + }); + + it("produces distinct lanes for distinct target sessions", () => { + const laneA = resolveNestedAgentLaneForSession("agent:ebao-next:discord:channel:1"); + const laneB = resolveNestedAgentLaneForSession("agent:ebao-vue:discord:channel:2"); + expect(laneA).not.toBe(laneB); + }); + + it("is deterministic for the same session key across calls", () => { + const key = "agent:ebao:discord:channel:1"; + expect(resolveNestedAgentLaneForSession(key)).toBe(resolveNestedAgentLaneForSession(key)); + }); + + it("trims whitespace around the session key before scoping", () => { + expect(resolveNestedAgentLaneForSession(" agent:ebao:main ")).toBe( + `${AGENT_LANE_NESTED}:agent:ebao:main`, + ); + }); +}); + +describe("isNestedAgentLane", () => { + it("returns true for the unscoped nested lane", () => { + expect(isNestedAgentLane(AGENT_LANE_NESTED)).toBe(true); + }); + + it("returns true for per-session nested lanes", () => { + expect(isNestedAgentLane(resolveNestedAgentLaneForSession("agent:a:main"))).toBe(true); + expect(isNestedAgentLane(`${AGENT_LANE_NESTED}:agent:a:main`)).toBe(true); + }); + + it("returns false for unrelated lanes", () => { + expect(isNestedAgentLane("main")).toBe(false); + expect(isNestedAgentLane("cron")).toBe(false); + expect(isNestedAgentLane("subagent")).toBe(false); + expect(isNestedAgentLane("session:agent:a:main")).toBe(false); + }); + + it("returns false for lanes that merely contain 'nested' as a substring", () => { + expect(isNestedAgentLane("deeply-nested-lane")).toBe(false); + expect(isNestedAgentLane("session:nested")).toBe(false); + expect(isNestedAgentLane("nestedfoo")).toBe(false); + }); + + it("returns false for empty or missing lane names", () => { + expect(isNestedAgentLane(undefined)).toBe(false); + expect(isNestedAgentLane("")).toBe(false); + }); +}); diff --git a/src/agents/lanes.ts b/src/agents/lanes.ts index e9fa2217cf7..68086a2b742 100644 --- a/src/agents/lanes.ts +++ b/src/agents/lanes.ts @@ -2,6 +2,8 @@ import { CommandLane } from "../process/lanes.js"; export const AGENT_LANE_NESTED = CommandLane.Nested; export const AGENT_LANE_SUBAGENT = CommandLane.Subagent; +const NESTED_LANE = "nested"; +const NESTED_LANE_PREFIX = `${NESTED_LANE}:`; export function resolveNestedAgentLane(lane?: string): string { const trimmed = lane?.trim(); @@ -12,3 +14,18 @@ export function resolveNestedAgentLane(lane?: string): string { } return trimmed; } + +export function resolveNestedAgentLaneForSession(sessionKey: string | undefined): string { + const trimmed = sessionKey?.trim(); + if (!trimmed) { + return AGENT_LANE_NESTED; + } + return `${NESTED_LANE_PREFIX}${trimmed}`; +} + +export function isNestedAgentLane(lane: string | undefined): boolean { + if (!lane) { + return false; + } + return lane === NESTED_LANE || lane.startsWith(NESTED_LANE_PREFIX); +} diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 097e2049bc1..90ab5e9e968 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -758,7 +758,7 @@ describe("sessions tools", () => { expect(agentCalls).toHaveLength(8); for (const call of agentCalls) { expect(call.params).toMatchObject({ - lane: "nested", + lane: expect.stringMatching(/^nested(?::|$)/), channel: "webchat", inputProvenance: { kind: "inter_session" }, }); @@ -938,7 +938,7 @@ describe("sessions tools", () => { expect(agentCalls).toHaveLength(4); for (const call of agentCalls) { expect(call.params).toMatchObject({ - lane: "nested", + lane: expect.stringMatching(/^nested(?::|$)/), channel: "webchat", inputProvenance: { kind: "inter_session" }, }); diff --git a/src/agents/tools/agent-step.ts b/src/agents/tools/agent-step.ts index fcd20a3ca96..06ba1e4261e 100644 --- a/src/agents/tools/agent-step.ts +++ b/src/agents/tools/agent-step.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { callGateway } from "../../gateway/call.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; -import { AGENT_LANE_NESTED } from "../lanes.js"; +import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { waitForAgentRunAndReadUpdatedAssistantReply } from "../run-wait.js"; export { readLatestAssistantReply } from "../run-wait.js"; @@ -36,7 +36,7 @@ export async function runAgentStep(params: { idempotencyKey: stepIdem, deliver: false, channel: params.channel ?? INTERNAL_MESSAGE_CHANNEL, - lane: params.lane ?? AGENT_LANE_NESTED, + lane: params.lane ?? resolveNestedAgentLaneForSession(params.sessionKey), extraSystemPrompt: params.extraSystemPrompt, inputProvenance: { kind: "inter_session", diff --git a/src/agents/tools/sessions-send-tool.a2a.ts b/src/agents/tools/sessions-send-tool.a2a.ts index 94af01c06a2..62e82134e30 100644 --- a/src/agents/tools/sessions-send-tool.a2a.ts +++ b/src/agents/tools/sessions-send-tool.a2a.ts @@ -3,7 +3,7 @@ import type { CallGatewayOptions } from "../../gateway/call.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; -import { AGENT_LANE_NESTED } from "../lanes.js"; +import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { readLatestAssistantReply, waitForAgentRun } from "../run-wait.js"; import { runAgentStep } from "./agent-step.js"; import { resolveAnnounceTarget } from "./sessions-announce-target.js"; @@ -92,7 +92,7 @@ export async function runSessionsSendA2AFlow(params: { message: incomingMessage, extraSystemPrompt: replyPrompt, timeoutMs: params.announceTimeoutMs, - lane: AGENT_LANE_NESTED, + lane: resolveNestedAgentLaneForSession(currentSessionKey), sourceSessionKey: nextSessionKey, sourceChannel: nextSessionKey === params.requesterSessionKey ? params.requesterChannel : targetChannel, @@ -123,7 +123,7 @@ export async function runSessionsSendA2AFlow(params: { message: "Agent-to-agent announce step.", extraSystemPrompt: announcePrompt, timeoutMs: params.announceTimeoutMs, - lane: AGENT_LANE_NESTED, + lane: resolveNestedAgentLaneForSession(params.targetSessionKey), sourceSessionKey: params.requesterSessionKey, sourceChannel: params.requesterChannel, sourceTool: "sessions_send", diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 2be2ef5ed89..e1be744a766 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -10,7 +10,7 @@ import { type GatewayMessageChannel, INTERNAL_MESSAGE_CHANNEL, } from "../../utils/message-channel.js"; -import { AGENT_LANE_NESTED } from "../lanes.js"; +import { resolveNestedAgentLaneForSession } from "../lanes.js"; import { readLatestAssistantReplySnapshot, waitForAgentRunAndReadUpdatedAssistantReply, @@ -276,7 +276,7 @@ export function createSessionsSendTool(opts?: { idempotencyKey, deliver: false, channel: INTERNAL_MESSAGE_CHANNEL, - lane: AGENT_LANE_NESTED, + lane: resolveNestedAgentLaneForSession(resolvedKey), extraSystemPrompt: agentMessageContext, inputProvenance: { kind: "inter_session", diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 5e8714b3671..2cfa3f5a82d 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -292,6 +292,29 @@ describe("deliverAgentCommandResult", () => { expect(line).toContain("ANNOUNCE_SKIP"); }); + it("prefixes per-session nested lanes with the same nested log context (#67502)", async () => { + const runtime = createRuntime(); + await runDelivery({ + runtime, + resultText: "ANNOUNCE_SKIP", + opts: { + message: "hello", + deliver: false, + lane: "nested:agent:ebao-next:discord:channel:1", + sessionKey: "agent:ebao-next:discord:channel:1", + runId: "run-announce", + messageChannel: "webchat", + }, + sessionEntry: undefined, + }); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const line = String((runtime.log as ReturnType).mock.calls[0]?.[0]); + expect(line).toContain("[agent:nested]"); + expect(line).toContain("session=agent:ebao-next:discord:channel:1"); + expect(line).toContain("ANNOUNCE_SKIP"); + }); + it("preserves audioAsVoice in JSON output envelopes", async () => { const runtime = createRuntime(); await runDelivery({ diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index b371c74b53d..3c1a218e310 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -150,7 +150,7 @@ describe("sessions_send gateway loopback", () => { const firstCall = spy.mock.calls[0]?.[0] as | { lane?: string; inputProvenance?: { kind?: string; sourceTool?: string } } | undefined; - expect(firstCall?.lane).toBe("nested"); + expect(firstCall?.lane).toMatch(/^nested(?::|$)/); expect(firstCall?.inputProvenance).toMatchObject({ kind: "inter_session", sourceTool: "sessions_send",