fix: route agent CLI via gateway
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { Command } from "commander";
|
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 { configureCommand } from "../commands/configure.js";
|
||||||
import { doctorCommand } from "../commands/doctor.js";
|
import { doctorCommand } from "../commands/doctor.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
@@ -387,9 +387,7 @@ Examples:
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("agent")
|
.command("agent")
|
||||||
.description(
|
.description("Run an agent turn via the Gateway (use --local for embedded)")
|
||||||
"Talk directly to the configured agent (no chat send; optional delivery)",
|
|
||||||
)
|
|
||||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||||
.option(
|
.option(
|
||||||
"-t, --to <number>",
|
"-t, --to <number>",
|
||||||
@@ -405,6 +403,11 @@ Examples:
|
|||||||
"--provider <provider>",
|
"--provider <provider>",
|
||||||
"Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
|
"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(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Send the agent's reply back to the selected provider (requires --to)",
|
"Send the agent's reply back to the selected provider (requires --to)",
|
||||||
@@ -430,9 +433,9 @@ Examples:
|
|||||||
typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
|
typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
|
||||||
setVerbose(verboseLevel === "on");
|
setVerbose(verboseLevel === "on");
|
||||||
// Build default deps (keeps parity with other commands; future-proofing).
|
// Build default deps (keeps parity with other commands; future-proofing).
|
||||||
void createDefaultDeps();
|
const deps = createDefaultDeps();
|
||||||
try {
|
try {
|
||||||
await agentCommand(opts, defaultRuntime);
|
await agentCliCommand(opts, defaultRuntime, deps);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
defaultRuntime.exit(1);
|
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,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!deliver) return;
|
if (!deliver) {
|
||||||
|
return { payloads: normalizedPayloads, meta: result.meta };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.length === 0) {
|
if (payloads.length === 0) {
|
||||||
runtime.log("No reply from agent.");
|
runtime.log("No reply from agent.");
|
||||||
return;
|
return { payloads: [], meta: result.meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
const deliveryTextLimit =
|
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,
|
defaultRuntime,
|
||||||
context.deps,
|
context.deps,
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then((result) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
runId,
|
runId,
|
||||||
status: "ok" as const,
|
status: "ok" as const,
|
||||||
summary: "completed",
|
summary: "completed",
|
||||||
|
result,
|
||||||
};
|
};
|
||||||
context.dedupe.set(`agent:${idem}`, {
|
context.dedupe.set(`agent:${idem}`, {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
|||||||
Reference in New Issue
Block a user