From 404c373153b3bd90584d99dea4b3fd1730c4e215 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 22:49:55 +0000 Subject: [PATCH] feat: add agent targeting + reply overrides --- CHANGELOG.md | 6 +- docs/cli/agent.md | 4 +- docs/tools/agent-send.md | 13 +++- src/cli/program/register.agent.ts | 8 +- src/commands/agent-via-gateway.ts | 62 ++++++++------- src/commands/agent.delivery.test.ts | 40 ++++++++++ src/commands/agent.test.ts | 49 ++++++++++++ src/commands/agent.ts | 32 +++++++- src/commands/agent/delivery.ts | 6 +- src/commands/agent/session.ts | 50 +++++++++--- src/commands/agent/types.ts | 8 ++ src/config/sessions/main-session.ts | 9 +++ src/gateway/protocol/schema/agent.ts | 4 + src/gateway/server-methods/agent.ts | 70 ++++++++++++++--- ...erver.agent.gateway-server-agent-a.test.ts | 77 +++++++++++++++++++ 15 files changed, 374 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29bcba162..dc7c39520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 4e711d2e0..fc0137455 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -7,6 +7,7 @@ read_when: # `clawdbot agent` Run an agent turn via the Gateway (use `--local` for embedded). +Use `--agent ` 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" ``` - diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md index 7f371a7b5..6ba228a78 100644 --- a/docs/tools/agent-send.md +++ b/docs/tools/agent-send.md @@ -14,13 +14,15 @@ runtime on the current machine. - Required: `--message ` - Session selection: - `--to ` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or** - - `--session-id ` reuses an existing session by id + - `--session-id ` reuses an existing session by id, **or** + - `--agent ` 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:` 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 `: persist thinking level (GPT-5.2 + Codex models only) - `--verbose `: persist verbose level - `--timeout `: override agent timeout diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 621debef1..79d76c3f8 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -17,12 +17,16 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti .requiredOption("-m, --message ", "Message body for the agent") .option("-t, --to ", "Recipient number in E.164 used to derive the session key") .option("--session-id ", "Use an explicit session id") + .option("--agent ", "Agent id (overrides routing bindings)") .option("--thinking ", "Thinking level: off | minimal | low | medium | high") .option("--verbose ", "Persist agent verbose level for the session") .option( "--channel ", `Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`, ) + .option("--reply-to ", "Delivery target override (separate from session routing)") + .option("--reply-channel ", "Delivery channel override (separate from routing)") + .option("--reply-account ", "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")}`, ) diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 8e8494392..43d8c0711 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -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; - 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; 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 or --session-id to choose a session"); + if (!opts.to && !opts.sessionId && !opts.agent) { + throw new Error("Pass --to , --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); } } diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 81e55927a..0068d3dae 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -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; diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 198440d02..2ddb2f5c5 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -45,6 +45,7 @@ function mockConfig( storePath: string, agentOverrides?: Partial["defaults"]>>, telegramOverrides?: Partial>, + 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"); + }); + }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 206f560ae..802250634 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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 or --session-id to choose a session"); + if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) { + throw new Error("Pass --to , --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, diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index f6498a78a..c8f874a6b 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -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; diff --git a/src/commands/agent/session.ts b/src/commands/agent/session.ts index ddccf2217..ab9da524d 100644 --- a/src/commands/agent/session.ts +++ b/src/commands/agent/session.ts @@ -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; + 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 diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index ff0637669..2480380a3 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -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|...) diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 0d627354a..bda603594 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -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; diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 0d594b3a9..e49c675a5 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -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()), diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 51f8efe95..9b22c613e 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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, diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index cea69eade..d9111058e 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -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; + 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"];