diff --git a/src/cli/program.ts b/src/cli/program.ts index 98bc51352..d67b43a70 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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 ", "Message body for the agent") .option( "-t, --to ", @@ -405,6 +403,11 @@ Examples: "--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); diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts new file mode 100644 index 000000000..cd0867582 --- /dev/null +++ b/src/commands/agent-via-gateway.test.ts @@ -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) { + 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 }); + } + }); +}); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts new file mode 100644 index 000000000..e0084de5e --- /dev/null +++ b/src/commands/agent-via-gateway.ts @@ -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; + 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; + 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 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({ + 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); + } +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 18599c6a0..4e96b55a9 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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 }; } diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6bc1d6df4..f9497f025 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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(),