diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cc030c5cac..532a1a4396a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre. - Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle. - Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong. - WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch. diff --git a/docs/cli/agent.md b/docs/cli/agent.md index a19dc441daf2..de5bab7fdc20 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -13,6 +13,7 @@ Use `--agent ` to target a configured agent directly. Pass at least one session selector: - `--to ` +- `--session-key ` - `--session-id ` - `--agent ` @@ -24,6 +25,7 @@ Related: - `-m, --message `: required message body - `-t, --to `: recipient used to derive the session key +- `--session-key `: explicit session key to use for routing - `--session-id `: explicit session id - `--agent `: agent id; overrides routing bindings - `--model `: model override for this run (`provider/model` or model id) @@ -44,6 +46,8 @@ Related: openclaw agent --to +15555550123 --message "status update" --deliver openclaw agent --agent ops --message "Summarize logs" openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs" +openclaw agent --session-key agent:ops:incident-42 --message "Summarize status" +openclaw agent --agent ops --session-key incident-42 --message "Summarize status" openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" @@ -57,6 +61,7 @@ openclaw agent --agent ops --message "Run locally" --local - `--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. - 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. - `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing. +- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent::`, and `--agent` must match the key's agent id when both are provided. Bare non-sentinel keys are scoped to `--agent` when supplied, or to the configured default agent otherwise; for example, `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. Literal `global` and `unknown` remain unscoped only when no `--agent` is supplied; in that case, embedded fallback and store ownership use the configured default agent. - `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly. - Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs. - If the Gateway accepts an agent run but the CLI times out waiting for the final reply, embedded fallback uses a fresh explicit `gateway-fallback-*` session/run id and reports `meta.fallbackReason: "gateway_timeout"` plus the fallback session fields. This avoids racing the Gateway-owned transcript lock or silently replacing the original routed conversation session. diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index 5fbf2c906ce8..e35f5cb947fe 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -15,7 +15,7 @@ programmatic delivery. ```bash - openclaw agent --message "What is the weather today?" + openclaw agent --agent main --message "What is the weather today?" ``` This sends the message through the Gateway and prints the reply. @@ -32,6 +32,9 @@ programmatic delivery. # Reuse an existing session openclaw agent --session-id abc123 --message "Continue the task" + + # Target an exact session key + openclaw agent --session-key agent:ops:incident-42 --message "Summarize status" ``` @@ -55,6 +58,7 @@ programmatic delivery. | ----------------------------- | ----------------------------------------------------------- | | `--message \` | Message to send (required) | | `--to \` | Derive session key from a target (phone, chat id) | +| `--session-key \` | Use an explicit session key | | `--agent \` | Target a configured agent (uses its `main` session) | | `--session-id \` | Reuse an existing session by id | | `--local` | Force local embedded runtime (skip Gateway) | @@ -75,6 +79,14 @@ programmatic delivery. - If the Gateway is unreachable, the CLI **falls back** to the local embedded run. - Session selection: `--to` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`). +- `--session-key` selects an explicit key. Agent-prefixed keys must use + `agent::`, and `--agent` must match that agent id when + both are supplied. Bare non-sentinel keys are scoped to `--agent` when + supplied; for example, `--agent ops --session-key incident-42` routes to + `agent:ops:incident-42`. Without `--agent`, bare non-sentinel keys are scoped + to the configured default agent. Literal `global` and `unknown` remain + unscoped only when no `--agent` is supplied; in that case, embedded fallback + and store ownership use the configured default agent. - Thinking and verbose flags persist into the session store. - Output: plain text by default, or `--json` for structured payload + metadata. - With `--json --deliver`, the JSON includes delivery status for sent, @@ -90,6 +102,12 @@ openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json # Turn with thinking level openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium +# Exact session key +openclaw agent --session-key agent:ops:incident-42 --message "Summarize status" + +# Legacy key scoped to an agent +openclaw agent --agent ops --session-key incident-42 --message "Summarize status" + # Deliver to a different channel than the session openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin" ``` diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 0153d63beb1b..6666f667c0da 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -22,9 +22,12 @@ import { buildOutboundSessionContext } from "../infra/outbound/session-context.j import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js"; import { + classifySessionKeyShape, + isUnscopedSessionKeySentinel, isSubagentSessionKey, normalizeAgentId, resolveAgentIdFromSessionKey, + scopeLegacySessionKeyToAgent, } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; @@ -48,6 +51,7 @@ import { markAutoFallbackPrimaryProbe, resolveAutoFallbackPrimaryProbe, resolveAgentDir, + resolveDefaultAgentId, resolveEffectiveModelFallbacks, resolveSessionAgentId, resolveAgentSkillsFilter, @@ -322,8 +326,11 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run if (!message.trim()) { throw new Error("Message (--message) is required"); } - if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) { - throw new Error("Pass --to , --session-id, or --agent to choose a session"); + const rawExplicitSessionKey = opts.sessionKey?.trim(); + if (!opts.to && !opts.sessionId && !rawExplicitSessionKey && !opts.agentId) { + throw new Error( + "Pass --to , --session-key, --session-id, or --agent to choose a session", + ); } const { cfg } = await resolveAgentRuntimeConfig(runtime, { @@ -346,8 +353,28 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run ); } } - if (agentIdOverride && opts.sessionKey) { - const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey); + const shouldScopeDefaultAgentKey = + rawExplicitSessionKey && + !agentIdOverride && + classifySessionKeyShape(rawExplicitSessionKey) === "legacy_or_alias" && + !isUnscopedSessionKeySentinel(rawExplicitSessionKey); + const explicitSessionKey = scopeLegacySessionKeyToAgent({ + agentId: + agentIdOverride ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg) : undefined), + sessionKey: rawExplicitSessionKey, + mainKey: cfg.session?.mainKey, + }); + if (explicitSessionKey && classifySessionKeyShape(explicitSessionKey) === "malformed_agent") { + throw new Error( + `Invalid --session-key "${explicitSessionKey}". Agent-prefixed session keys must use agent::.`, + ); + } + if ( + agentIdOverride && + explicitSessionKey && + classifySessionKeyShape(explicitSessionKey) === "agent" + ) { + const sessionAgentId = resolveAgentIdFromSessionKey(explicitSessionKey); if (sessionAgentId !== agentIdOverride) { throw new Error( `Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`, @@ -381,7 +408,7 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run cfg, to: opts.to, sessionId: opts.sessionId, - sessionKey: opts.sessionKey, + sessionKey: explicitSessionKey, agentId: agentIdOverride, }); @@ -398,7 +425,7 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run const sessionAgentId = agentIdOverride ?? resolveSessionAgentId({ - sessionKey: sessionKey ?? opts.sessionKey?.trim(), + sessionKey: sessionKey ?? explicitSessionKey, config: cfg, }); const outboundSession = buildOutboundSessionContext({ diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index a2cc1d2dba51..8801670e7fb6 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -24,6 +24,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, + isUnscopedSessionKeySentinel, normalizeAgentId, normalizeMainKey, } from "../../routing/session-key.js"; @@ -218,7 +219,9 @@ export function resolveSessionKeyForRequest(opts: { }) : undefined); const storeAgentId = explicitSessionKey - ? resolveAgentIdFromSessionKey(explicitSessionKey) + ? isUnscopedSessionKeySentinel(explicitSessionKey) + ? (requestedAgentId ?? defaultAgentId) + : resolveAgentIdFromSessionKey(explicitSessionKey) : (requestedAgentId ?? defaultAgentId); const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId, diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 81c732367140..1220a64cfb70 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -134,6 +134,16 @@ describe("registerAgentCommands", () => { expect(deps).toEqual({ deps: true }); }); + it("forwards an explicit session key to the agent command", async () => { + await runCli(["agent", "--message", "hi", "--session-key", "agent:ops:incident-42"]); + + const [options, callRuntime, deps] = commandCall(agentCliCommandMock); + expect((options as { message?: string }).message).toBe("hi"); + expect((options as { sessionKey?: string }).sessionKey).toBe("agent:ops:incident-42"); + expect(callRuntime).toBe(runtime); + expect(deps).toEqual({ deps: true }); + }); + it("runs agents add and computes hasFlags based on explicit options", async () => { await runCli(["agents", "add", "alpha"]); const [alphaOptions, alphaRuntime, alphaFlags] = commandCall(agentsAddCommandMock, 0); diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 88ecaf2f9ffa..46eb96a198fa 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -68,6 +68,7 @@ export function registerAgentCommands( .description("Run an agent turn via the Gateway (use --local for embedded)") .requiredOption("-m, --message ", "Message body for the agent") .option("-t, --to ", "Recipient number in E.164 used to derive the session key") + .option("--session-key ", "Explicit session key (agent::, or scoped to --agent)") .option("--session-id ", "Use an explicit session id") .option("--agent ", "Agent id (overrides routing bindings)") .option("--model ", "Model override for this run (provider/model or model id)") @@ -102,6 +103,10 @@ ${theme.heading("Examples:")} ${formatHelpExamples([ ['openclaw agent --to +15555550123 --message "status update"', "Start a new session."], ['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."], + [ + 'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"', + "Target an exact session key.", + ], [ 'openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium', "Target a session with explicit thinking level.", diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index c2d5a1293ddc..3f6155526f4b 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -42,6 +42,7 @@ function mockConfig(storePath: string, overrides?: Partial) { timeoutSeconds: 600, ...overrides?.agents?.defaults, }, + ...(overrides?.agents?.list ? { list: overrides.agents.list } : {}), }, session: { store: storePath, @@ -188,6 +189,178 @@ describe("agentCliCommand", () => { }); }); + it("uses an explicit session key as the gateway session selector", async () => { + await withTempStore(async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", sessionKey: "agent:main:incident-42" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request"); + const params = requireRecord(request.params, "gateway request params"); + expect(params.sessionKey).toBe("agent:main:incident-42"); + expect(params.sessionId).toBeUndefined(); + expect(params.to).toBeUndefined(); + expect(agentCommand).not.toHaveBeenCalled(); + }); + }); + + it("scopes legacy explicit session keys to the requested agent", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", agent: "ops", sessionKey: "incident-42" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBe("ops"); + expect(params.sessionKey).toBe("agent:ops:incident-42"); + }, + { agents: { list: [{ id: "main" }, { id: "ops" }] } }, + ); + }); + + it("accepts agent-prefixed session keys when only casing differs from --agent", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand( + { message: "hi", agent: "OPS", sessionKey: "agent:OPS:incident-42" }, + runtime, + ); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBe("ops"); + expect(params.sessionKey).toBe("agent:OPS:incident-42"); + }, + { agents: { list: [{ id: "main" }, { id: "ops" }] } }, + ); + }); + + it("scopes legacy explicit session keys to the default agent when no agent is requested", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", sessionKey: "incident-42" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBeUndefined(); + expect(params.sessionKey).toBe("agent:ops:incident-42"); + }, + { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, + ); + }); + + it("prefers explicit session keys when a session id is also supplied", async () => { + await withTempStore( + async ({ store }) => { + fs.writeFileSync( + store, + JSON.stringify({ + "agent:main:main": { sessionId: "existing-main-session", updatedAt: 1 }, + }), + ); + mockGatewaySuccessReply(); + + await agentCliCommand( + { + message: "hi", + sessionId: "existing-main-session", + sessionKey: "agent:ops:incident-42", + }, + runtime, + ); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.sessionId).toBe("existing-main-session"); + expect(params.sessionKey).toBe("agent:ops:incident-42"); + }, + { agents: { list: [{ id: "main" }, { id: "ops" }] } }, + ); + }); + + it("scopes legacy global session keys to the requested agent before gateway dispatch", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", agent: "ops", sessionKey: "global" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBe("ops"); + expect(params.sessionKey).toBe("agent:ops:global"); + }, + { agents: { list: [{ id: "main" }, { id: "ops" }] } }, + ); + }); + + it("preserves unscoped global session keys when no agent is requested", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", sessionKey: "global" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBeUndefined(); + expect(params.sessionKey).toBe("global"); + }, + { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, + ); + }); + + it("preserves unscoped unknown session keys when no agent is requested", async () => { + await withTempStore( + async () => { + mockGatewaySuccessReply(); + + await agentCliCommand({ message: "hi", sessionKey: "unknown" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + const request = requireRecord( + requireFirstCallArg(callGateway, "gateway"), + "gateway request", + ); + const params = requireRecord(request.params, "gateway request params"); + expect(params.agentId).toBeUndefined(); + expect(params.sessionKey).toBe("unknown"); + }, + { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, + ); + }); + it("stays silent when the gateway returns an intentional empty reply", async () => { await withTempStore(async () => { callGateway.mockResolvedValue({ @@ -323,6 +496,27 @@ describe("agentCliCommand", () => { }); }); + it("preserves explicit session keys for embedded fallback when the gateway closes", async () => { + await withTempStore(async () => { + callGateway.mockRejectedValue(createGatewayClosedError()); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", sessionKey: "agent:main:incident-42" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(agentCommand).toHaveBeenCalledTimes(1); + const fallbackOpts = requireRecord( + requireFirstCallArg(agentCommand, "embedded agent"), + "embedded agent options", + ); + expect(fallbackOpts.sessionKey).toBe("agent:main:incident-42"); + expect(fallbackOpts.resultMetaOverrides).toMatchObject({ + transport: "embedded", + fallbackFrom: "gateway", + }); + }); + }); + it("does not fall back to embedded agent for gateway request errors", async () => { await withTempStore(async () => { callGateway.mockRejectedValue( @@ -388,6 +582,63 @@ describe("agentCliCommand", () => { }); }); + it("uses the explicit session key agent for timeout fallback sessions", async () => { + await withTempStore(async () => { + callGateway.mockRejectedValue(createGatewayTimeoutError()); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", sessionKey: "agent:ops:incident-42" }, runtime); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { + sessionId?: string; + sessionKey?: string; + }; + expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); + expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`); + }); + }); + + it("uses the default-scoped legacy session key agent for timeout fallback sessions", async () => { + await withTempStore( + async () => { + callGateway.mockRejectedValue(createGatewayTimeoutError()); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", sessionKey: "incident-42" }, runtime); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { + sessionId?: string; + sessionKey?: string; + }; + expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); + expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`); + }, + { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, + ); + }); + + it("uses the default agent for timeout fallback with unscoped global session keys", async () => { + await withTempStore( + async () => { + callGateway.mockRejectedValue(createGatewayTimeoutError()); + mockLocalAgentReply(); + + await agentCliCommand({ message: "hi", sessionKey: "global" }, runtime); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as { + sessionId?: string; + sessionKey?: string; + }; + expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); + expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`); + }, + { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, + ); + }); + it("keeps timeout fallback from replacing the routed conversation session key", async () => { await withTempStore(async () => { callGateway.mockRejectedValue(createGatewayTimeoutError()); @@ -505,6 +756,81 @@ describe("agentCliCommand", () => { }); }); + it("passes explicit session keys to local embedded runs", async () => { + await withTempStore(async () => { + mockLocalAgentReply(); + + await agentCliCommand( + { + message: "hi", + sessionKey: "agent:main:incident-42", + local: true, + }, + runtime, + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).toHaveBeenCalledTimes(1); + const localOpts = requireRecord( + requireFirstCallArg(agentCommand, "embedded agent"), + "embedded agent options", + ); + expect(localOpts.sessionKey).toBe("agent:main:incident-42"); + }); + }); + + it("scopes legacy explicit session keys before local embedded runs", async () => { + await withTempStore(async () => { + mockLocalAgentReply(); + + await agentCliCommand( + { + message: "hi", + agent: "ops", + sessionKey: "incident-42", + local: true, + }, + runtime, + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).toHaveBeenCalledTimes(1); + const localOpts = requireRecord( + requireFirstCallArg(agentCommand, "embedded agent"), + "embedded agent options", + ); + expect(localOpts.agentId).toBe("ops"); + expect(localOpts.sessionKey).toBe("agent:ops:incident-42"); + }); + }); + + it("rejects malformed agent-prefixed session keys before gateway or local fallback", async () => { + await withTempStore(async () => { + await expect( + agentCliCommand({ message: "hi", sessionKey: "agent:main" }, runtime), + ).rejects.toThrow( + 'Invalid --session-key "agent:main". Agent-prefixed session keys must use agent::.', + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).not.toHaveBeenCalled(); + }); + }); + + it("rejects explicit session keys whose agent does not match --agent", async () => { + await withTempStore(async () => { + await expect( + agentCliCommand( + { message: "hi", agent: "ops", sessionKey: "agent:main:incident-42" }, + runtime, + ), + ).rejects.toThrow('Agent id "ops" does not match session key agent "main".'); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).not.toHaveBeenCalled(); + }); + }); + it("forces bundle MCP cleanup on embedded fallback", async () => { await withTempStore(async () => { callGateway.mockRejectedValue(createGatewayClosedError()); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 0f741e899bf1..b64d31cd19c0 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; -import { listAgentIds } from "../agents/agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.types.js"; import { withProgress } from "../cli/progress.js"; @@ -10,7 +10,13 @@ import { callGateway, isGatewayTransportError, randomIdempotencyKey } from "../g import { ADMIN_SCOPE } from "../gateway/operator-scopes.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; import { routeLogsToStderr } from "../logging/console.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { + classifySessionKeyShape, + isUnscopedSessionKeySentinel, + normalizeAgentId, + resolveAgentIdFromSessionKey, + scopeLegacySessionKeyToAgent, +} from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; @@ -48,6 +54,7 @@ type AgentCliOpts = { model?: string; to?: string; sessionId?: string; + sessionKey?: string; thinking?: string; verbose?: string; json?: boolean; @@ -114,6 +121,58 @@ function isGatewayAgentEmbeddedFallbackError(err: unknown): boolean { return isGatewayTransportError(err); } +function validateExplicitSessionKeyForDispatch( + opts: Pick, +): void { + const sessionKey = opts.sessionKey?.trim(); + if (!sessionKey) { + return; + } + + if (classifySessionKeyShape(sessionKey) === "malformed_agent") { + throw new Error( + `Invalid --session-key "${sessionKey}". Agent-prefixed session keys must use agent::.`, + ); + } + + const agentIdRaw = opts.agent?.trim() || undefined; + if (!agentIdRaw || classifySessionKeyShape(sessionKey) !== "agent") { + return; + } + const agentId = normalizeAgentId(agentIdRaw); + const sessionAgentId = resolveAgentIdFromSessionKey(sessionKey); + if (sessionAgentId !== agentId) { + throw new Error( + `Agent id "${agentIdRaw}" does not match session key agent "${sessionAgentId}".`, + ); + } +} + +function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): AgentCliOpts { + const rawSessionKey = opts.sessionKey?.trim(); + const isLegacySessionKey = + rawSessionKey && classifySessionKeyShape(rawSessionKey) === "legacy_or_alias"; + const agentIdRaw = opts.agent?.trim(); + const shouldScopeDefaultAgentKey = + isLegacySessionKey && !agentIdRaw && !isUnscopedSessionKeySentinel(rawSessionKey); + const cfg = + isLegacySessionKey && (agentIdRaw || shouldScopeDefaultAgentKey) + ? getRuntimeConfig() + : undefined; + const sessionKey = scopeLegacySessionKeyToAgent({ + agentId: agentIdRaw ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg!) : undefined), + sessionKey: opts.sessionKey, + mainKey: cfg?.session?.mainKey, + }); + if (sessionKey === opts.sessionKey) { + return opts; + } + return { + ...opts, + sessionKey, + }; +} + function createGatewayTimeoutFallbackSessionId(): string { return `${GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX}${randomUUID()}`; } @@ -129,6 +188,34 @@ function createGatewayTimeoutFallbackSession(agentId?: string): { }; } +function resolveAgentIdForGatewayTimeoutFallback(opts: AgentCliOpts): string | undefined { + const explicitSessionKey = opts.sessionKey?.trim(); + if (classifySessionKeyShape(explicitSessionKey) === "agent") { + return resolveAgentIdFromSessionKey(explicitSessionKey); + } + if (isUnscopedSessionKeySentinel(explicitSessionKey)) { + return resolveDefaultAgentId(getRuntimeConfig()); + } + + const agentIdRaw = opts.agent?.trim(); + if (agentIdRaw) { + return normalizeAgentId(agentIdRaw); + } + + if (!opts.to && !opts.sessionId) { + return undefined; + } + const cfg = getRuntimeConfig(); + const resolvedSessionKey = resolveSessionKeyForRequest({ + cfg, + to: opts.to, + sessionId: opts.sessionId, + }).sessionKey; + return classifySessionKeyShape(resolvedSessionKey) === "agent" + ? resolveAgentIdFromSessionKey(resolvedSessionKey) + : undefined; +} + function buildGatewayJsonResponse(response: GatewayAgentResponse): GatewayAgentResponse { const deliveryStatus = response.result?.deliveryStatus; if (deliveryStatus === undefined) { @@ -143,14 +230,15 @@ function buildGatewayJsonResponse(response: GatewayAgentResponse): GatewayAgentR async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) { protectJsonStdout(opts); const body = (opts.message ?? "").trim(); + const explicitSessionKey = opts.sessionKey?.trim(); if (!body) { throw new Error( - `Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent ')} or pass --to/--session-id for an existing conversation.`, + `Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent ')} or pass --to/--session-key/--session-id for an existing conversation.`, ); } - if (!opts.to && !opts.sessionId && !opts.agent) { + if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) { throw new Error( - `No target session selected. Use --agent , --session-id , or --to . Run ${formatCliCommand("openclaw agents list")} to see agents.`, + `No target session selected. Use --agent , --session-key , --session-id , or --to . Run ${formatCliCommand("openclaw agents list")} to see agents.`, ); } @@ -176,6 +264,7 @@ async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) { agentId, to: opts.to, sessionId: opts.sessionId, + sessionKey: explicitSessionKey, }).sessionKey; const channel = normalizeMessageChannel(opts.channel); @@ -248,22 +337,25 @@ async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) { export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) { protectJsonStdout(opts); + const dispatchOpts = normalizeSessionKeyOptsForDispatch(opts); + validateExplicitSessionKeyForDispatch(dispatchOpts); const localOpts = { - ...opts, - agentId: opts.agent, - replyAccountId: opts.replyAccount, + ...dispatchOpts, + agentId: dispatchOpts.agent, + replyAccountId: dispatchOpts.replyAccount, cleanupBundleMcpOnRunEnd: true, cleanupCliLiveSessionOnRunEnd: true, }; - if (opts.local === true) { + if (dispatchOpts.local === true) { return await agentCommand(localOpts, runtime, deps); } try { - return await agentViaGatewayCommand(opts, runtime); + return await agentViaGatewayCommand(dispatchOpts, runtime); } catch (err) { if (isGatewayAgentTimeoutError(err)) { - const fallbackSession = createGatewayTimeoutFallbackSession(opts.agent); + const fallbackAgentId = resolveAgentIdForGatewayTimeoutFallback(dispatchOpts); + const fallbackSession = createGatewayTimeoutFallbackSession(fallbackAgentId); runtime.error?.( `EMBEDDED FALLBACK: Gateway agent timed out; running embedded agent with fresh session ${fallbackSession.sessionId}: ${String(err)}`, ); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index eb390e36abec..f0fa7f269bd6 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1125,4 +1125,57 @@ describe("agentCommand", () => { ); }); }); + + it("uses explicit session keys for embedded runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, undefined, undefined, [{ id: "main" }, { id: "ops" }]); + + await agentCommand({ message: "hi", sessionKey: "agent:ops:incident-42" }, runtime); + + let callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); + expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); + + await agentCommand({ message: "hi", agentId: "ops", sessionKey: "incident-42" }, runtime); + + callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); + + await agentCommand({ message: "hi", agentId: "ops", sessionKey: "global" }, runtime); + + callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("agent:ops:global"); + }); + }); + + it("scopes bare explicit session keys to the default agent for embedded runs", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store, undefined, undefined, [{ id: "ops", default: true }, { id: "main" }]); + + await agentCommand({ message: "hi", sessionKey: "incident-42" }, runtime); + + let callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("agent:ops:incident-42"); + + await agentCommand({ message: "hi", sessionKey: "global" }, runtime); + + callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("global"); + expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); + + await agentCommand({ message: "hi", sessionKey: "unknown" }, runtime); + + callArgs = getLastEmbeddedCall(); + expect(callArgs?.agentId).toBe("ops"); + expect(callArgs?.sessionKey).toBe("unknown"); + expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`); + }); + }); }); diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index 2e450f631356..7415a77c6f7b 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -14,6 +14,8 @@ import { parseAgentSessionKey, resolveEventSessionKey, scopedHeartbeatWakeOptions, + isUnscopedSessionKeySentinel, + scopeLegacySessionKeyToAgent, toAgentStoreSessionKey, } from "./session-key.js"; @@ -33,6 +35,47 @@ describe("classifySessionKeyShape", () => { }); }); +describe("scopeLegacySessionKeyToAgent", () => { + it("scopes legacy aliases to the requested agent", () => { + expect(scopeLegacySessionKeyToAgent({ agentId: "Ops", sessionKey: "Incident-42" })).toBe( + "agent:ops:incident-42", + ); + }); + + it("honors configured main-key aliases when scoping legacy keys", () => { + expect( + scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "main", mainKey: "work" }), + ).toBe("agent:ops:work"); + }); + + it("preserves already agent-prefixed keys", () => { + expect( + scopeLegacySessionKeyToAgent({ + agentId: "ops", + sessionKey: "agent:main:incident-42", + }), + ).toBe("agent:main:incident-42"); + }); + + it("scopes global and unknown legacy aliases to the requested agent", () => { + expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "global" })).toBe( + "agent:ops:global", + ); + expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "UNKNOWN" })).toBe( + "agent:ops:unknown", + ); + }); +}); + +describe("isUnscopedSessionKeySentinel", () => { + it("recognizes literal global and unknown sentinels", () => { + expect(isUnscopedSessionKeySentinel("global")).toBe(true); + expect(isUnscopedSessionKeySentinel("UNKNOWN")).toBe(true); + expect(isUnscopedSessionKeySentinel("agent:ops:global")).toBe(false); + expect(isUnscopedSessionKeySentinel("incident-42")).toBe(false); + }); +}); + describe("session key backward compatibility", () => { function expectBackwardCompatibleDirectSessionKey(key: string) { expect(classifySessionKeyShape(key)).toBe("agent"); diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index f24ba6abf24d..d339ac1789a1 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -131,6 +131,31 @@ export function classifySessionKeyShape(sessionKey: string | undefined | null): : "legacy_or_alias"; } +export function isUnscopedSessionKeySentinel(sessionKey: string | undefined | null): boolean { + const lowered = normalizeLowercaseStringOrEmpty(sessionKey); + return lowered === "global" || lowered === "unknown"; +} + +export function scopeLegacySessionKeyToAgent(params: { + agentId?: string | undefined; + sessionKey?: string | undefined; + mainKey?: string | undefined; +}): string | undefined { + const raw = (params.sessionKey ?? "").trim(); + if (!raw) { + return undefined; + } + const agentId = params.agentId?.trim(); + if (!agentId || classifySessionKeyShape(raw) !== "legacy_or_alias") { + return raw; + } + return toAgentStoreSessionKey({ + agentId, + requestKey: raw, + mainKey: params.mainKey, + }); +} + export function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) {