feat: add agent targeting + reply overrides

This commit is contained in:
Peter Steinberger
2026-01-18 22:49:55 +00:00
parent 024691e4e7
commit 404c373153
15 changed files with 374 additions and 64 deletions

View File

@@ -6,13 +6,11 @@ Docs: https://docs.clawd.bot
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps; time design is actively being explored. See https://docs.clawd.bot/date-time. (#1150) — thanks @shiv19.
- TUI: add animated waiting shimmer status in the terminal UI. (#1196) — thanks @vignesh07.
- CLI: show Telegram bot username in channel status (probe/runtime).
- CLI: add agent targeting and reply routing overrides for `clawdbot agent`. (#1165)
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
## 2026.1.18-4

View File

@@ -7,6 +7,7 @@ read_when:
# `clawdbot agent`
Run an agent turn via the Gateway (use `--local` for embedded).
Use `--agent <id>` to target a configured agent directly.
Related:
- Agent send tool: [Agent send](/tools/agent-send)
@@ -15,6 +16,7 @@ Related:
```bash
clawdbot agent --to +15555550123 --message "status update" --deliver
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
```

View File

@@ -14,13 +14,15 @@ runtime on the current machine.
- Required: `--message <text>`
- Session selection:
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
- `--session-id <id>` reuses an existing session by id
- `--session-id <id>` reuses an existing session by id, **or**
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
- Runs the same embedded agent runtime as normal inbound replies.
- Thinking/verbose flags persist into the session store.
- Output:
- default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
@@ -28,16 +30,21 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
```bash
clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
```
## Flags
- `--local`: run locally (requires model provider API keys in your shell)
- `--deliver`: send the reply to the chosen channel (requires `--to`)
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--deliver`: send the reply to the chosen channel
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
- `--reply-to`: delivery target override
- `--reply-channel`: delivery channel override
- `--reply-account`: delivery account id override
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout

View File

@@ -17,12 +17,16 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
)
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")
.option("--reply-account <id>", "Delivery account id override")
.option(
"--local",
"Run the embedded agent locally (requires model provider API keys in your shell)",
@@ -30,7 +34,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
)
.option(
"--deliver",
"Send the agent's reply back to the selected channel (requires --to)",
"Send the agent's reply back to the selected channel",
false,
)
.option("--json", "Output result as JSON", false)
@@ -44,9 +48,11 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
`
Examples:
clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
)

View File

@@ -2,9 +2,10 @@ import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveSessionKey, resolveStorePath } from "../config/sessions.js";
import { resolveSessionKeyForRequest } from "./agent/session.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { listAgentIds } from "../agents/agent-scope.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
@@ -31,6 +32,7 @@ type GatewayAgentResponse = {
export type AgentCliOpts = {
message: string;
agent?: string;
to?: string;
sessionId?: string;
thinking?: string;
@@ -39,6 +41,9 @@ export type AgentCliOpts = {
timeout?: string;
deliver?: boolean;
channel?: string;
replyTo?: string;
replyChannel?: string;
replyAccount?: string;
bestEffortDeliver?: boolean;
lane?: string;
runId?: string;
@@ -46,27 +51,6 @@ export type AgentCliOpts = {
local?: boolean;
};
function resolveGatewaySessionKey(opts: {
cfg: ReturnType<typeof loadConfig>;
to?: string;
sessionId?: string;
}): string | undefined {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null;
let sessionKey: string | undefined = ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined;
if (opts.sessionId && (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId)) {
const foundKey = Object.keys(store).find((key) => store[key]?.sessionId === opts.sessionId);
if (foundKey) sessionKey = foundKey;
}
return sessionKey;
}
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
const raw =
@@ -98,19 +82,30 @@ function formatPayloadForLog(payload: {
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) throw new Error("Message (--message) is required");
if (!opts.to && !opts.sessionId) {
throw new Error("Pass --to <E.164> or --session-id to choose a session");
if (!opts.to && !opts.sessionId && !opts.agent) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
}
const cfg = loadConfig();
const agentIdRaw = opts.agent?.trim();
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${agentIdRaw}". Use "clawdbot agents list" to see configured agents.`,
);
}
}
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
const sessionKey = resolveGatewaySessionKey({
const sessionKey = resolveSessionKeyForRequest({
cfg,
agentId,
to: opts.to,
sessionId: opts.sessionId,
});
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
@@ -126,12 +121,16 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
method: "agent",
params: {
message: body,
agentId,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
channel,
replyChannel: opts.replyChannel,
replyAccountId: opts.replyAccount,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
@@ -166,14 +165,19 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
}
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
const localOpts = {
...opts,
agentId: opts.agent,
replyAccountId: opts.replyAccount,
};
if (opts.local === true) {
return await agentCommand(opts, runtime, deps);
return await agentCommand(localOpts, runtime, deps);
}
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
return await agentCommand(opts, runtime, deps);
return await agentCommand(localOpts, runtime, deps);
}
}

View File

@@ -220,6 +220,46 @@ describe("deliverAgentCommandResult", () => {
);
});
it("uses reply overrides for delivery routing", async () => {
const cfg = {} as ClawdbotConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastChannel: "telegram",
lastTo: "123",
lastAccountId: "legacy",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
opts: {
message: "hello",
deliver: true,
to: "+15551234567",
replyTo: "#reports",
replyChannel: "slack",
replyAccountId: "ops",
},
sessionEntry,
result,
payloads: result.payloads,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
expect.objectContaining({ channel: "slack", to: "#reports", accountId: "ops" }),
);
});
it("prefixes nested agent outputs with context", async () => {
const cfg = {} as ClawdbotConfig;
const deps = {} as CliDeps;

View File

@@ -45,6 +45,7 @@ function mockConfig(
storePath: string,
agentOverrides?: Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>,
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
agentsList?: Array<{ id: string; default?: boolean }>,
) {
configSpy.mockReturnValue({
agents: {
@@ -54,6 +55,7 @@ function mockConfig(
workspace: path.join(home, "clawd"),
...agentOverrides,
},
list: agentsList,
},
session: { store: storePath, mainKey: "main" },
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
@@ -195,6 +197,30 @@ describe("agentCommand", () => {
});
});
it("derives session key from --agent when no routing target is provided", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionKey).toBe("agent:ops:main");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
});
});
it("rejects unknown agent overrides", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow(
'Unknown agent id "ghost"',
);
});
});
it("defaults thinking to low for reasoning-capable models", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
@@ -296,4 +322,27 @@ describe("agentCommand", () => {
}
});
});
it("uses reply channel as the message channel context", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.messageChannel).toBe("slack");
});
});
it("logs output when delivery is disabled", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
expect(runtime.log).toHaveBeenCalledWith("ok");
});
});
});

View File

@@ -1,4 +1,5 @@
import {
listAgentIds,
resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
@@ -53,6 +54,7 @@ import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveSession } from "./agent/session.js";
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
import type { AgentCommandOpts } from "./agent/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
export async function agentCommand(
opts: AgentCommandOpts,
@@ -61,13 +63,31 @@ export async function agentCommand(
) {
const body = (opts.message ?? "").trim();
if (!body) throw new Error("Message (--message) is required");
if (!opts.to && !opts.sessionId && !opts.sessionKey) {
throw new Error("Pass --to <E.164> or --session-id to choose a session");
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
}
const cfg = loadConfig();
const agentIdOverrideRaw = opts.agentId?.trim();
const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined;
if (agentIdOverride) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentIdOverride)) {
throw new Error(
`Unknown agent id "${agentIdOverrideRaw}". Use "clawdbot agents list" to see configured agents.`,
);
}
}
if (agentIdOverride && opts.sessionKey) {
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey);
if (sessionAgentId !== agentIdOverride) {
throw new Error(
`Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`,
);
}
}
const agentCfg = cfg.agents?.defaults;
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const workspace = await ensureAgentWorkspace({
@@ -114,6 +134,7 @@ export async function agentCommand(
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
agentId: agentIdOverride,
});
const {
@@ -346,7 +367,10 @@ export async function agentCommand(
let fallbackProvider = provider;
let fallbackModel = model;
try {
const messageChannel = resolveMessageChannel(opts.messageChannel, opts.channel);
const messageChannel = resolveMessageChannel(
opts.messageChannel,
opts.replyChannel ?? opts.channel,
);
const fallbackResult = await runWithModelFallback({
cfg,
provider,

View File

@@ -59,9 +59,9 @@ export async function deliverAgentCommandResult(params: {
const bestEffortDeliver = opts.bestEffortDeliver === true;
const deliveryPlan = resolveAgentDeliveryPlan({
sessionEntry,
requestedChannel: opts.channel,
explicitTo: opts.to,
accountId: opts.accountId,
requestedChannel: opts.replyChannel ?? opts.channel,
explicitTo: opts.replyTo ?? opts.to,
accountId: opts.replyAccountId ?? opts.accountId,
wantsDelivery: deliver,
});
const deliveryChannel = deliveryPlan.resolvedChannel;

View File

@@ -12,6 +12,7 @@ import {
evaluateSessionFreshness,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveExplicitAgentSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveSessionKey,
@@ -31,43 +32,72 @@ export type SessionResolution = {
persistedVerbose?: VerboseLevel;
};
export function resolveSession(opts: {
type SessionKeyResolution = {
sessionKey?: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
};
export function resolveSessionKeyForRequest(opts: {
cfg: ClawdbotConfig;
to?: string;
sessionId?: string;
sessionKey?: string;
}): SessionResolution {
agentId?: string;
}): SessionKeyResolution {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const explicitSessionKey = opts.sessionKey?.trim();
const explicitSessionKey =
opts.sessionKey?.trim() ||
resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: opts.agentId,
});
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
});
const sessionStore = loadSessionStore(storePath);
const now = Date.now();
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
let sessionKey: string | undefined =
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
if (
!explicitSessionKey &&
opts.sessionId &&
(!sessionEntry || sessionEntry.sessionId !== opts.sessionId)
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
) {
const foundKey = Object.keys(sessionStore).find(
(key) => sessionStore[key]?.sessionId === opts.sessionId,
);
if (foundKey) {
sessionKey = sessionKey ?? foundKey;
sessionEntry = sessionStore[foundKey];
}
if (foundKey) sessionKey = foundKey;
}
return { sessionKey, sessionStore, storePath };
}
export function resolveSession(opts: {
cfg: ClawdbotConfig;
to?: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
}): SessionResolution {
const sessionCfg = opts.cfg.session;
const { sessionKey, sessionStore, storePath } = resolveSessionKeyForRequest({
cfg: opts.cfg,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
});
const now = Date.now();
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
const resetType = resolveSessionResetType({ sessionKey });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
const fresh = sessionEntry

View File

@@ -11,6 +11,8 @@ export type AgentCommandOpts = {
message: string;
/** Optional image attachments for multimodal messages. */
images?: ImageContent[];
/** Agent id override (must exist in config). */
agentId?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
@@ -20,6 +22,12 @@ export type AgentCommandOpts = {
json?: boolean;
timeout?: string;
deliver?: boolean;
/** Override delivery target (separate from session routing). */
replyTo?: string;
/** Override delivery channel (separate from session routing). */
replyChannel?: string;
/** Override delivery account id (separate from session routing). */
replyAccountId?: string;
/** Message channel context (webchat|voicewake|whatsapp|...). */
messageChannel?: string;
channel?: string; // delivery channel (whatsapp|telegram|...)

View File

@@ -35,6 +35,15 @@ export function resolveAgentMainSessionKey(params: {
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
}
export function resolveExplicitAgentSessionKey(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId?: string | null;
}): string | undefined {
const agentId = params.agentId?.trim();
if (!agentId) return undefined;
return resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
}
export function canonicalizeMainSessionAlias(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId: string;

View File

@@ -45,14 +45,18 @@ export const PollParamsSchema = Type.Object(
export const AgentParamsSchema = Type.Object(
{
message: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
to: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
sessionId: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
channel: Type.Optional(Type.String()),
replyChannel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
replyAccountId: Type.Optional(Type.String()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),

View File

@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { agentCommand } from "../../commands/agent.js";
import { listAgentIds } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
resolveExplicitAgentSessionKey,
resolveAgentMainSessionKey,
type SessionEntry,
updateSessionStore,
@@ -21,6 +23,7 @@ import {
isGatewayMessageChannel,
normalizeMessageChannel,
} from "../../utils/message-channel.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { parseMessageWithAttachments } from "../chat-attachments.js";
import {
type AgentWaitParams,
@@ -51,7 +54,9 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const request = p as {
message: string;
agentId?: string;
to?: string;
replyTo?: string;
sessionId?: string;
sessionKey?: string;
thinking?: string;
@@ -63,7 +68,9 @@ export const agentHandlers: GatewayRequestHandlers = {
content?: unknown;
}>;
channel?: string;
replyChannel?: string;
accountId?: string;
replyAccountId?: string;
lane?: string;
extraSystemPrompt?: string;
idempotencyKey: string;
@@ -71,6 +78,7 @@ export const agentHandlers: GatewayRequestHandlers = {
label?: string;
spawnedBy?: string;
};
const cfg = loadConfig();
const idem = request.idempotencyKey;
const cached = context.dedupe.get(`agent:${idem}`);
if (cached) {
@@ -113,9 +121,12 @@ export const agentHandlers: GatewayRequestHandlers = {
return;
}
}
const rawChannel = typeof request.channel === "string" ? request.channel.trim() : "";
if (rawChannel) {
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
const channelHints = [request.channel, request.replyChannel]
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
for (const rawChannel of channelHints) {
const normalized = normalizeMessageChannel(rawChannel);
if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) {
respond(
@@ -130,10 +141,47 @@ export const agentHandlers: GatewayRequestHandlers = {
}
}
const requestedSessionKey =
const agentIdRaw = typeof request.agentId === "string" ? request.agentId.trim() : "";
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agent params: unknown agent id "${request.agentId}"`,
),
);
return;
}
}
const requestedSessionKeyRaw =
typeof request.sessionKey === "string" && request.sessionKey.trim()
? request.sessionKey.trim()
: undefined;
const requestedSessionKey =
requestedSessionKeyRaw ??
resolveExplicitAgentSessionKey({
cfg,
agentId,
});
if (agentId && requestedSessionKeyRaw) {
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
if (sessionAgentId !== agentId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agent params: agent "${request.agentId}" does not match session key agent "${sessionAgentId}"`,
),
);
return;
}
}
let resolvedSessionId = request.sessionId?.trim() || undefined;
let sessionEntry: SessionEntry | undefined;
let bestEffortDeliver = false;
@@ -204,12 +252,16 @@ export const agentHandlers: GatewayRequestHandlers = {
const wantsDelivery = request.deliver === true;
const explicitTo =
typeof request.to === "string" && request.to.trim() ? request.to.trim() : undefined;
typeof request.replyTo === "string" && request.replyTo.trim()
? request.replyTo.trim()
: typeof request.to === "string" && request.to.trim()
? request.to.trim()
: undefined;
const deliveryPlan = resolveAgentDeliveryPlan({
sessionEntry,
requestedChannel: request.channel,
requestedChannel: request.replyChannel ?? request.channel,
explicitTo,
accountId: request.accountId,
accountId: request.replyAccountId ?? request.accountId,
wantsDelivery,
});
@@ -219,9 +271,9 @@ export const agentHandlers: GatewayRequestHandlers = {
let resolvedTo = deliveryPlan.resolvedTo;
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfg = cfgForAgent ?? loadConfig();
const cfgResolved = cfgForAgent ?? cfg;
const fallback = resolveAgentOutboundTarget({
cfg,
cfg: cfgResolved,
plan: deliveryPlan,
targetMode: "implicit",
validateExplicitTarget: false,

View File

@@ -214,6 +214,83 @@ describe("gateway server agent", () => {
await server.close();
});
test("agent derives sessionKey from agentId", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
testState.agentsConfig = { list: [{ id: "ops" }] };
await writeSessionStore({
agentId: "ops",
entries: {
main: {
sessionId: "sess-ops",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", {
message: "hi",
agentId: "ops",
idempotencyKey: "idem-agent-id",
});
expect(res.ok).toBe(true);
const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionKey).toBe("agent:ops:main");
expect(call.sessionId).toBe("sess-ops");
ws.close();
await server.close();
});
test("agent rejects unknown reply channel", async () => {
registryState.registry = defaultRegistry;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", {
message: "hi",
replyChannel: "unknown-channel",
idempotencyKey: "idem-agent-reply-unknown",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("unknown channel");
const spy = vi.mocked(agentCommand);
expect(spy).not.toHaveBeenCalled();
ws.close();
await server.close();
});
test("agent rejects mismatched agentId and sessionKey", async () => {
registryState.registry = defaultRegistry;
testState.agentsConfig = { list: [{ id: "ops" }] };
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent", {
message: "hi",
agentId: "ops",
sessionKey: "agent:main:main",
idempotencyKey: "idem-agent-mismatch",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("does not match session key agent");
const spy = vi.mocked(agentCommand);
expect(spy).not.toHaveBeenCalled();
ws.close();
await server.close();
});
test("agent forwards accountId to agentCommand", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];