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

@@ -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|...)