feat: add agent targeting + reply overrides
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}`,
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|...)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user