fix: route agent CLI via gateway
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import chalk from "chalk";
|
||||
import { Command } from "commander";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { agentCliCommand } from "../commands/agent-via-gateway.js";
|
||||
import { configureCommand } from "../commands/configure.js";
|
||||
import { doctorCommand } from "../commands/doctor.js";
|
||||
import { healthCommand } from "../commands/health.js";
|
||||
@@ -387,9 +387,7 @@ Examples:
|
||||
|
||||
program
|
||||
.command("agent")
|
||||
.description(
|
||||
"Talk directly to the configured agent (no chat send; optional delivery)",
|
||||
)
|
||||
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||
.option(
|
||||
"-t, --to <number>",
|
||||
@@ -405,6 +403,11 @@ Examples:
|
||||
"--provider <provider>",
|
||||
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
||||
)
|
||||
.option(
|
||||
"--local",
|
||||
"Run the embedded agent locally (requires provider API keys in your shell)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--deliver",
|
||||
"Send the agent's reply back to the selected provider (requires --to)",
|
||||
@@ -430,9 +433,9 @@ Examples:
|
||||
typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
|
||||
setVerbose(verboseLevel === "on");
|
||||
// Build default deps (keeps parity with other commands; future-proofing).
|
||||
void createDefaultDeps();
|
||||
const deps = createDefaultDeps();
|
||||
try {
|
||||
await agentCommand(opts, defaultRuntime);
|
||||
await agentCliCommand(opts, defaultRuntime, deps);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
126
src/commands/agent-via-gateway.test.ts
Normal file
126
src/commands/agent-via-gateway.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(),
|
||||
randomIdempotencyKey: () => "idem-1",
|
||||
}));
|
||||
vi.mock("./agent.js", () => ({
|
||||
agentCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import * as configModule from "../config/config.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { agentCommand } from "./agent.js";
|
||||
import { agentCliCommand } from "./agent-via-gateway.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const configSpy = vi.spyOn(configModule, "loadConfig");
|
||||
|
||||
function mockConfig(storePath: string, overrides?: Partial<ClawdbotConfig>) {
|
||||
configSpy.mockReturnValue({
|
||||
agent: {
|
||||
timeoutSeconds: 600,
|
||||
...overrides?.agent,
|
||||
},
|
||||
session: {
|
||||
store: storePath,
|
||||
mainKey: "main",
|
||||
...overrides?.session,
|
||||
},
|
||||
gateway: overrides?.gateway,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("agentCliCommand", () => {
|
||||
it("uses gateway by default", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-"));
|
||||
const store = path.join(dir, "sessions.json");
|
||||
mockConfig(store);
|
||||
|
||||
vi.mocked(callGateway).mockResolvedValue({
|
||||
runId: "idem-1",
|
||||
status: "ok",
|
||||
result: {
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: { stub: true },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await agentCliCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith("hello");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to embedded agent when gateway fails", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-"));
|
||||
const store = path.join(dir, "sessions.json");
|
||||
mockConfig(store);
|
||||
|
||||
vi.mocked(callGateway).mockRejectedValue(
|
||||
new Error("gateway not connected"),
|
||||
);
|
||||
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
|
||||
rt.log?.("local");
|
||||
return { payloads: [{ text: "local" }], meta: { stub: true } };
|
||||
});
|
||||
|
||||
try {
|
||||
await agentCliCommand({ message: "hi", to: "+1555" }, runtime);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith("local");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips gateway when --local is set", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-"));
|
||||
const store = path.join(dir, "sessions.json");
|
||||
mockConfig(store);
|
||||
|
||||
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
|
||||
rt.log?.("local");
|
||||
return { payloads: [{ text: "local" }], meta: { stub: true } };
|
||||
});
|
||||
|
||||
try {
|
||||
await agentCliCommand(
|
||||
{
|
||||
message: "hi",
|
||||
to: "+1555",
|
||||
local: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith("local");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
194
src/commands/agent-via-gateway.ts
Normal file
194
src/commands/agent-via-gateway.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { agentCommand } from "./agent.js";
|
||||
|
||||
type AgentGatewayResult = {
|
||||
payloads?: Array<{
|
||||
text?: string;
|
||||
mediaUrl?: string | null;
|
||||
mediaUrls?: string[];
|
||||
}>;
|
||||
meta?: unknown;
|
||||
};
|
||||
|
||||
type GatewayAgentResponse = {
|
||||
runId?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
result?: AgentGatewayResult;
|
||||
};
|
||||
|
||||
export type AgentCliOpts = {
|
||||
message: string;
|
||||
to?: string;
|
||||
sessionId?: string;
|
||||
thinking?: string;
|
||||
verbose?: string;
|
||||
json?: boolean;
|
||||
timeout?: string;
|
||||
deliver?: boolean;
|
||||
provider?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
lane?: string;
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
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 = sessionCfg?.mainKey ?? "main";
|
||||
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 =
|
||||
opts.timeout !== undefined
|
||||
? Number.parseInt(String(opts.timeout), 10)
|
||||
: (opts.cfg.agent?.timeoutSeconds ?? 600);
|
||||
if (Number.isNaN(raw) || raw <= 0) {
|
||||
throw new Error("--timeout must be a positive integer (seconds)");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeProvider(raw?: string): string | undefined {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
return normalized === "imsg" ? "imessage" : normalized;
|
||||
}
|
||||
|
||||
function formatPayloadForLog(payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string | null;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if (payload.text) lines.push(payload.text.trimEnd());
|
||||
const mediaUrl =
|
||||
typeof payload.mediaUrl === "string" && payload.mediaUrl.trim()
|
||||
? payload.mediaUrl.trim()
|
||||
: undefined;
|
||||
const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []);
|
||||
for (const url of media) lines.push(`MEDIA:${url}`);
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
|
||||
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
|
||||
|
||||
const sessionKey = resolveGatewaySessionKey({
|
||||
cfg,
|
||||
to: opts.to,
|
||||
sessionId: opts.sessionId,
|
||||
});
|
||||
|
||||
const channel = normalizeProvider(opts.provider) ?? "whatsapp";
|
||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||
|
||||
const response = await callGateway<GatewayAgentResponse>({
|
||||
method: "agent",
|
||||
params: {
|
||||
message: body,
|
||||
to: opts.to,
|
||||
sessionId: opts.sessionId,
|
||||
sessionKey,
|
||||
thinking: opts.thinking,
|
||||
deliver: Boolean(opts.deliver),
|
||||
channel,
|
||||
timeout: timeoutSeconds,
|
||||
lane: opts.lane,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
idempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: gatewayTimeoutMs,
|
||||
clientName: "cli",
|
||||
mode: "cli",
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(response, null, 2));
|
||||
return response;
|
||||
}
|
||||
|
||||
const result = response?.result;
|
||||
const payloads = result?.payloads ?? [];
|
||||
|
||||
if (payloads.length === 0) {
|
||||
runtime.log(
|
||||
response?.summary ? String(response.summary) : "No reply from agent.",
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
for (const payload of payloads) {
|
||||
const out = formatPayloadForLog(payload);
|
||||
if (out) runtime.log(out);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function agentCliCommand(
|
||||
opts: AgentCliOpts,
|
||||
runtime: RuntimeEnv,
|
||||
deps?: CliDeps,
|
||||
) {
|
||||
if (opts.local === true) {
|
||||
return await agentCommand(opts, 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);
|
||||
}
|
||||
}
|
||||
@@ -598,12 +598,14 @@ export async function agentCommand(
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (!deliver) return;
|
||||
if (!deliver) {
|
||||
return { payloads: normalizedPayloads, meta: result.meta };
|
||||
}
|
||||
}
|
||||
|
||||
if (payloads.length === 0) {
|
||||
runtime.log("No reply from agent.");
|
||||
return;
|
||||
return { payloads: [], meta: result.meta };
|
||||
}
|
||||
|
||||
const deliveryTextLimit =
|
||||
@@ -787,4 +789,11 @@ export async function agentCommand(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPayloads = payloads.map((p) => ({
|
||||
text: p.text ?? "",
|
||||
mediaUrl: p.mediaUrl ?? null,
|
||||
mediaUrls: p.mediaUrls ?? (p.mediaUrl ? [p.mediaUrl] : undefined),
|
||||
}));
|
||||
return { payloads: normalizedPayloads, meta: result.meta };
|
||||
}
|
||||
|
||||
@@ -240,11 +240,12 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
defaultRuntime,
|
||||
context.deps,
|
||||
)
|
||||
.then(() => {
|
||||
.then((result) => {
|
||||
const payload = {
|
||||
runId,
|
||||
status: "ok" as const,
|
||||
summary: "completed",
|
||||
result,
|
||||
};
|
||||
context.dedupe.set(`agent:${idem}`, {
|
||||
ts: Date.now(),
|
||||
|
||||
Reference in New Issue
Block a user