Skip to content

Commit 765765a

Browse files
committed
fix(agent): route explicit channel targets per recipient
1 parent 04e774e commit 765765a

7 files changed

Lines changed: 123 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- Agents/CLI: derive `openclaw agent --agent ... --channel ... --to ...` sessions from the explicit channel recipient instead of the agent main session, while preserving explicit `--session-id` and `--session-key` precedence. Fixes #41483; refs #41557, #60614, and #60621. Thanks @pingfanfan.
1617
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
1718
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
1819
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.

docs/cli/agent.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Related:
2929
- `--model <id>`: model override for this run (`provider/model` or model id)
3030
- `--thinking <level>`: agent thinking level (`off`, `minimal`, `low`, `medium`, `high`, plus provider-supported custom levels such as `xhigh`, `adaptive`, or `max`)
3131
- `--verbose <on|off>`: persist verbose level for the session
32-
- `--channel <channel>`: delivery channel; omit to use the main session channel
32+
- `--channel <channel>`: delivery channel; with `--agent` and `--to`, also scopes the derived session key to that channel recipient
3333
- `--reply-to <target>`: delivery target override
3434
- `--reply-channel <channel>`: delivery channel override
3535
- `--reply-account <id>`: delivery account override
@@ -56,7 +56,7 @@ openclaw agent --agent ops --message "Run locally" --local
5656
- `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs.
5757
- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive.
5858
- Gateway-backed runs leave Gateway-owned MCP loopback resources under the running Gateway process; older clients may still send the historical cleanup flag, but the Gateway accepts it as a compatibility no-op.
59-
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
59+
- `--agent ... --channel ... --to ...` uses an agent-scoped channel-recipient session key. `--reply-channel` and `--reply-account` affect reply delivery, not session routing.
6060
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
6161
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs.
6262
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.

src/agents/agent-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ async function prepareAgentCommandExecution(
332332
const sessionResolution = resolveSession({
333333
cfg,
334334
to: opts.to,
335+
channel: opts.channel,
335336
sessionId: opts.sessionId,
336337
sessionKey: opts.sessionKey,
337338
agentId: agentIdOverride,

src/agents/command/session.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { loadSessionStore } from "../../config/sessions/store-load.js";
2222
import type { SessionEntry } from "../../config/sessions/types.js";
2323
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2424
import {
25+
buildAgentPeerSessionKey,
2526
buildAgentMainSessionKey,
2627
DEFAULT_AGENT_ID,
2728
normalizeAgentId,
@@ -196,6 +197,7 @@ export function resolveStoredSessionKeyForSessionId(opts: {
196197
export function resolveSessionKeyForRequest(opts: {
197198
cfg: OpenClawConfig;
198199
to?: string;
200+
channel?: string;
199201
sessionId?: string;
200202
sessionKey?: string;
201203
agentId?: string;
@@ -206,9 +208,16 @@ export function resolveSessionKeyForRequest(opts: {
206208
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(opts.cfg));
207209
const requestedAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
208210
const requestedSessionId = opts.sessionId?.trim() || undefined;
211+
const requestedSessionKey = opts.sessionKey?.trim();
212+
const explicitChannel = opts.channel?.trim();
213+
const explicitTo = opts.to?.trim();
214+
const shouldPreferExplicitChannelSession =
215+
Boolean(requestedAgentId && explicitChannel && explicitTo) &&
216+
!requestedSessionId &&
217+
!requestedSessionKey;
209218
const explicitSessionKey =
210-
opts.sessionKey?.trim() ||
211-
(!requestedSessionId
219+
requestedSessionKey ||
220+
(!requestedSessionId && !shouldPreferExplicitChannelSession
212221
? resolveExplicitAgentSessionKey({
213222
cfg: opts.cfg,
214223
agentId: requestedAgentId,
@@ -223,8 +232,21 @@ export function resolveSessionKeyForRequest(opts: {
223232
const sessionStore = loadSessionStore(storePath);
224233

225234
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
235+
const explicitChannelSessionKey =
236+
requestedAgentId && explicitChannel && explicitTo && !requestedSessionId
237+
? buildAgentPeerSessionKey({
238+
agentId: storeAgentId,
239+
mainKey,
240+
channel: explicitChannel,
241+
peerKind: "direct",
242+
peerId: explicitTo,
243+
dmScope: "per-channel-peer",
244+
})
245+
: undefined;
226246
let sessionKey: string | undefined =
227-
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) : undefined);
247+
explicitSessionKey ??
248+
explicitChannelSessionKey ??
249+
(ctx ? resolveSessionKey(scope, ctx, mainKey, storeAgentId) : undefined);
228250

229251
if (ctx && !requestedAgentId && !requestedSessionId && !explicitSessionKey) {
230252
const legacyMainSession = resolveLegacyMainStoreSessionForDefaultAgent({
@@ -284,6 +306,7 @@ export function resolveSessionKeyForRequest(opts: {
284306
export function resolveSession(opts: {
285307
cfg: OpenClawConfig;
286308
to?: string;
309+
channel?: string;
287310
sessionId?: string;
288311
sessionKey?: string;
289312
agentId?: string;
@@ -292,6 +315,7 @@ export function resolveSession(opts: {
292315
const { sessionKey, sessionStore, storePath } = resolveSessionKeyForRequest({
293316
cfg: opts.cfg,
294317
to: opts.to,
318+
channel: opts.channel,
295319
sessionId: opts.sessionId,
296320
sessionKey: opts.sessionKey,
297321
agentId: opts.agentId,

src/commands/agent-via-gateway.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
3333
timeoutSeconds: 600,
3434
...overrides?.agents?.defaults,
3535
},
36+
list: overrides?.agents?.list,
3637
},
3738
session: {
3839
store: storePath,
@@ -138,6 +139,39 @@ describe("agentCliCommand", () => {
138139
});
139140
});
140141

142+
it("passes an agent-scoped recipient session key for explicit channel targets", async () => {
143+
await withTempStore(
144+
async () => {
145+
mockGatewaySuccessReply();
146+
147+
await agentCliCommand(
148+
{
149+
message: "hi",
150+
agent: "ops",
151+
channel: "whatsapp",
152+
to: "+15551234567",
153+
},
154+
runtime,
155+
);
156+
157+
expect(callGateway).toHaveBeenCalledTimes(1);
158+
expect(callGateway.mock.calls[0]?.[0]).toMatchObject({
159+
params: {
160+
agentId: "ops",
161+
channel: "whatsapp",
162+
to: "+15551234567",
163+
sessionKey: "agent:ops:whatsapp:direct:+15551234567",
164+
},
165+
});
166+
},
167+
{
168+
agents: {
169+
list: [{ id: "ops" }],
170+
},
171+
} satisfies Partial<OpenClawConfig>,
172+
);
173+
});
174+
141175
it("routes diagnostics to stderr before JSON gateway execution", async () => {
142176
await withTempStore(async () => {
143177
const response = {

src/commands/agent-via-gateway.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,15 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
123123
? NO_GATEWAY_TIMEOUT_MS // no timeout (timer-safe max)
124124
: Math.max(10_000, (timeoutSeconds + 30) * 1000);
125125

126+
const channel = normalizeMessageChannel(opts.channel);
126127
const sessionKey = resolveSessionKeyForRequest({
127128
cfg,
128129
agentId,
129130
to: opts.to,
131+
channel,
130132
sessionId: opts.sessionId,
131133
}).sessionKey;
132134

133-
const channel = normalizeMessageChannel(opts.channel);
134135
const idempotencyKey = normalizeOptionalString(opts.runId) || randomIdempotencyKey();
135136

136137
const response: GatewayAgentResponse = await withProgress(

src/commands/agent/session.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ vi.mock("../../config/sessions/paths.js", () => ({
2828
}));
2929

3030
vi.mock("../../agents/agent-scope.js", async () => {
31-
const { normalizeAgentId } = await vi.importActual<
32-
typeof import("../../routing/session-key.js")
33-
>("../../routing/session-key.js");
31+
const { normalizeAgentId } = await vi.importActual<typeof import("../../routing/session-key.js")>(
32+
"../../routing/session-key.js",
33+
);
3434
return {
3535
listAgentIds: mocks.listAgentIds,
3636
resolveDefaultAgentId: (cfg: OpenClawConfig) => {
@@ -102,6 +102,59 @@ describe("resolveSessionKeyForRequest", () => {
102102
expect(result.storePath).toBe(MYBOT_STORE_PATH);
103103
});
104104

105+
it("derives an agent-scoped recipient key for explicit --agent --channel --to sessions", async () => {
106+
setupMainAndMybotStorePaths();
107+
mockStoresByPath({
108+
[MYBOT_STORE_PATH]: {},
109+
});
110+
111+
const result = resolveSessionKeyForRequest({
112+
cfg: baseCfg,
113+
agentId: "mybot",
114+
channel: "whatsapp",
115+
to: "+15551234567",
116+
});
117+
118+
expect(result.sessionKey).toBe("agent:mybot:whatsapp:direct:+15551234567");
119+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
120+
});
121+
122+
it("keeps global scope for non-channel --agent --to session derivation", async () => {
123+
setupMainAndMybotStorePaths();
124+
mockStoresByPath({
125+
[MYBOT_STORE_PATH]: {},
126+
});
127+
128+
const result = resolveSessionKeyForRequest({
129+
cfg: { session: { scope: "global" } } satisfies OpenClawConfig,
130+
agentId: "mybot",
131+
to: "+15551234567",
132+
});
133+
134+
expect(result.sessionKey).toBe("global");
135+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
136+
});
137+
138+
it("keeps --session-id lookup ahead of explicit channel recipient derivation", async () => {
139+
setupMainAndMybotStorePaths();
140+
mockStoresByPath({
141+
[MYBOT_STORE_PATH]: {
142+
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
143+
},
144+
});
145+
146+
const result = resolveSessionKeyForRequest({
147+
cfg: baseCfg,
148+
agentId: "mybot",
149+
channel: "whatsapp",
150+
to: "+15551234567",
151+
sessionId: "target-session-id",
152+
});
153+
154+
expect(result.sessionKey).toBe("agent:mybot:main");
155+
expect(result.storePath).toBe(MYBOT_STORE_PATH);
156+
});
157+
105158
it("migrates legacy main-store main-key sessions for plain --to default-agent requests", async () => {
106159
setupMainAndMybotStorePaths();
107160
const mainStore = {

0 commit comments

Comments
 (0)