Skip to content

Commit 8e58aef

Browse files
SurielSuriel
authored andcommitted
Add stdin and file message inputs to agent CLI
1 parent 070b045 commit 8e58aef

4 files changed

Lines changed: 156 additions & 11 deletions

File tree

src/cli/program/register.agent-turn.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export function registerAgentTurnCommand(
3434
program
3535
.command("agent")
3636
.description("Run an agent turn via the Gateway (use --local for embedded)")
37-
.requiredOption("-m, --message <text>", "Message body for the agent")
37+
.option("-m, --message <text>", "Message body for the agent")
38+
.option("--message-file <path>", "Read the message body from a UTF-8 file")
39+
.option("--message-stdin", "Read the message body from stdin", false)
3840
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
3941
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
4042
.option("--session-id <id>", "Use an explicit session id")
@@ -71,6 +73,8 @@ ${theme.heading("Examples:")}
7173
${formatHelpExamples([
7274
['openclaw agent --to +15555550123 --message "status update"', "Start a new session."],
7375
['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."],
76+
["cat prompt.txt | openclaw agent --agent ops --message-stdin", "Read a message from stdin."],
77+
["openclaw agent --agent ops --message-file prompt.txt", "Read a message from a file."],
7478
[
7579
'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"',
7680
"Target an exact session key.",

src/cli/program/register.agent.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ describe("registerAgentCommands", () => {
105105
expect(deps).toBeUndefined();
106106
});
107107

108+
it("forwards file and stdin message input options", async () => {
109+
await runCli(["agent", "--message-file", "/tmp/prompt.txt", "--agent", "ops"]);
110+
111+
const [fileOptions] = commandCall(agentCliCommandMock, 0);
112+
expect((fileOptions as { messageFile?: string }).messageFile).toBe("/tmp/prompt.txt");
113+
expect((fileOptions as { messageStdin?: boolean }).messageStdin).toBe(false);
114+
115+
await runCli(["agent", "--message-stdin", "--agent", "ops"]);
116+
117+
const [stdinOptions] = commandCall(agentCliCommandMock, 1);
118+
expect((stdinOptions as { messageStdin?: boolean }).messageStdin).toBe(true);
119+
});
120+
108121
it("runs agent command with verbose disabled for --verbose off", async () => {
109122
await runCli(["agent", "--message", "hi", "--verbose", "off"]);
110123

src/commands/agent-via-gateway.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,50 @@ describe("agentCliCommand", () => {
308308
});
309309
});
310310

311+
it("reads the gateway message from a UTF-8 file", async () => {
312+
await withTempStore(async ({ dir }) => {
313+
mockGatewaySuccessReply();
314+
const messagePath = path.join(dir, "prompt.txt");
315+
fs.writeFileSync(messagePath, " file hello \n", "utf8");
316+
317+
await agentCliCommand({ messageFile: messagePath, to: "+1555" }, runtime);
318+
319+
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
320+
const params = requireRecord(request.params, "gateway request params");
321+
expect(params.message).toBe("file hello");
322+
});
323+
});
324+
325+
it("requires exactly one message input source", async () => {
326+
await withTempStore(async ({ dir }) => {
327+
const messagePath = path.join(dir, "prompt.txt");
328+
fs.writeFileSync(messagePath, "file hello", "utf8");
329+
330+
await expect(
331+
agentCliCommand({ message: "hi", messageFile: messagePath, to: "+1555" }, runtime),
332+
).rejects.toThrow(
333+
"Choose exactly one message input: --message, --message-file, or --message-stdin.",
334+
);
335+
336+
await expect(agentCliCommand({ to: "+1555" }, runtime)).rejects.toThrow(
337+
"Choose exactly one message input: --message, --message-file, or --message-stdin.",
338+
);
339+
expect(callGateway).not.toHaveBeenCalled();
340+
});
341+
});
342+
343+
it("rejects empty file message input", async () => {
344+
await withTempStore(async ({ dir }) => {
345+
const messagePath = path.join(dir, "prompt.txt");
346+
fs.writeFileSync(messagePath, " \n\t ", "utf8");
347+
348+
await expect(
349+
agentCliCommand({ messageFile: messagePath, to: "+1555" }, runtime),
350+
).rejects.toThrow("Missing message.");
351+
expect(callGateway).not.toHaveBeenCalled();
352+
});
353+
});
354+
311355
it.each(["/new", "/RESET", "/reset check status"] as const)(
312356
"uses backend admin authority for %s gateway commands",
313357
async (message) => {

src/commands/agent-via-gateway.ts

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Gateway-first agent CLI implementation with embedded fallback for local/runtime failures.
22
import { randomUUID } from "node:crypto";
3+
import { readFileSync, statSync } from "node:fs";
34
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
45
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
56
import {
@@ -62,9 +63,12 @@ const EMBEDDED_FALLBACK_META = {
6263
} as const;
6364
const GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX = "gateway-fallback-";
6465
const GATEWAY_TRANSIENT_CONNECT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 15_000] as const;
66+
const MAX_AGENT_MESSAGE_INPUT_BYTES = 10 * 1024 * 1024;
6567

6668
type AgentCliOpts = {
67-
message: string;
69+
message?: string;
70+
messageFile?: string;
71+
messageStdin?: boolean;
6872
agent?: string;
6973
model?: string;
7074
to?: string;
@@ -94,6 +98,7 @@ type AgentCliProcessLike = {
9498
type AgentCliDeps = CliDeps & {
9599
process?: AgentCliProcessLike;
96100
};
101+
type ResolvedAgentCliOpts = AgentCliOpts & { message: string };
97102
type AgentGatewayCallIdentity = Pick<
98103
Parameters<typeof callGateway>[0],
99104
"clientName" | "mode" | "scopes"
@@ -192,6 +197,86 @@ function resolveGatewayAgentTimeoutMs(timeoutSeconds: number): number {
192197
return resolveTimerTimeoutMs((timeoutSeconds + 30) * 1000, 10_000, 10_000);
193198
}
194199

200+
function formatMissingMessageError(): string {
201+
return `Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')}, --message-file <path>, or --message-stdin.`;
202+
}
203+
204+
function formatMessageInputSizeError(source: string): string {
205+
return `Message input from ${source} exceeds ${MAX_AGENT_MESSAGE_INPUT_BYTES} bytes. Use a smaller prompt or split the request.`;
206+
}
207+
208+
function assertAgentMessageInputSize(value: string, source: string): void {
209+
if (Buffer.byteLength(value, "utf8") > MAX_AGENT_MESSAGE_INPUT_BYTES) {
210+
throw new Error(formatMessageInputSizeError(source));
211+
}
212+
}
213+
214+
function readAgentMessageFile(filePath: string): string {
215+
const stat = statSync(filePath);
216+
if (stat.size > MAX_AGENT_MESSAGE_INPUT_BYTES) {
217+
throw new Error(formatMessageInputSizeError("--message-file"));
218+
}
219+
const value = readFileSync(filePath, "utf8");
220+
assertAgentMessageInputSize(value, "--message-file");
221+
return value;
222+
}
223+
224+
async function readAgentMessageStdin(): Promise<string> {
225+
if (process.stdin.isTTY) {
226+
throw new Error(
227+
"--message-stdin requires piped input. Pipe a prompt on stdin or use --message-file <path>.",
228+
);
229+
}
230+
const chunks: string[] = [];
231+
let byteLength = 0;
232+
process.stdin.setEncoding("utf8");
233+
for await (const chunk of process.stdin) {
234+
const text =
235+
typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8");
236+
byteLength += Buffer.byteLength(text, "utf8");
237+
if (byteLength > MAX_AGENT_MESSAGE_INPUT_BYTES) {
238+
throw new Error(formatMessageInputSizeError("--message-stdin"));
239+
}
240+
chunks.push(text);
241+
}
242+
return chunks.join("");
243+
}
244+
245+
async function resolveAgentCliMessageInput(opts: AgentCliOpts): Promise<ResolvedAgentCliOpts> {
246+
const messageFile = normalizeOptionalString(opts.messageFile);
247+
const modes = [
248+
typeof opts.message === "string",
249+
Boolean(messageFile),
250+
opts.messageStdin === true,
251+
].filter(Boolean);
252+
if (modes.length !== 1) {
253+
throw new Error(
254+
"Choose exactly one message input: --message, --message-file, or --message-stdin.",
255+
);
256+
}
257+
258+
let rawMessage = "";
259+
if (typeof opts.message === "string") {
260+
rawMessage = opts.message;
261+
assertAgentMessageInputSize(rawMessage, "--message");
262+
} else if (messageFile) {
263+
rawMessage = readAgentMessageFile(messageFile);
264+
} else {
265+
rawMessage = await readAgentMessageStdin();
266+
}
267+
268+
const message = rawMessage.trim();
269+
if (!message) {
270+
throw new Error(formatMissingMessageError());
271+
}
272+
return {
273+
...opts,
274+
message,
275+
messageFile: undefined,
276+
messageStdin: undefined,
277+
};
278+
}
279+
195280
async function getGatewayDispatchConfig(options?: {
196281
skipShellEnvFallback?: boolean;
197282
}): Promise<OpenClawConfig> {
@@ -231,7 +316,7 @@ function isGatewayAgentTimeoutError(err: unknown): boolean {
231316
return err instanceof Error && err.message.includes("gateway request timeout for agent");
232317
}
233318

234-
function isControlCommandThatMustNotFallback(opts: Pick<AgentCliOpts, "message">): boolean {
319+
function isControlCommandThatMustNotFallback(opts: Pick<ResolvedAgentCliOpts, "message">): boolean {
235320
const normalized = opts.message.trim().toLowerCase();
236321
return normalized === "/compact" || normalized.startsWith("/compact ");
237322
}
@@ -288,7 +373,7 @@ function validateExplicitSessionKeyForDispatch(
288373
}
289374
}
290375

291-
async function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): Promise<AgentCliOpts> {
376+
async function normalizeSessionKeyOptsForDispatch<T extends AgentCliOpts>(opts: T): Promise<T> {
292377
const rawSessionKey = opts.sessionKey?.trim();
293378
const rawTo = opts.to?.trim();
294379
if (!rawSessionKey && !opts.sessionId?.trim() && classifySessionKeyShape(rawTo) === "agent") {
@@ -620,17 +705,15 @@ function formatInFlightGatewayAgentMessage(response: GatewayAgentResponse): stri
620705
}
621706

622707
async function agentViaGatewayCommand(
623-
opts: AgentCliOpts,
708+
opts: ResolvedAgentCliOpts,
624709
runtime: RuntimeEnv,
625710
signalBridge: ReturnType<typeof createAgentCliSignalBridge>,
626711
) {
627712
protectJsonStdout(opts);
628-
const body = (opts.message ?? "").trim();
713+
const body = opts.message.trim();
629714
const explicitSessionKey = opts.sessionKey?.trim();
630715
if (!body) {
631-
throw new Error(
632-
`Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or pass --to/--session-key/--session-id for an existing conversation.`,
633-
);
716+
throw new Error(formatMissingMessageError());
634717
}
635718
if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) {
636719
throw new Error(
@@ -806,7 +889,7 @@ async function agentViaGatewayCommand(
806889
}
807890

808891
async function agentViaGatewayCommandWithTransientRetries(
809-
opts: AgentCliOpts,
892+
opts: ResolvedAgentCliOpts,
810893
runtime: RuntimeEnv,
811894
signalBridge: ReturnType<typeof createAgentCliSignalBridge>,
812895
) {
@@ -839,7 +922,8 @@ export async function agentCliCommand(
839922
deps?: AgentCliDeps,
840923
) {
841924
protectJsonStdout(opts);
842-
const dispatchOpts = await normalizeSessionKeyOptsForDispatch(opts);
925+
const resolvedOpts = await resolveAgentCliMessageInput(opts);
926+
const dispatchOpts = await normalizeSessionKeyOptsForDispatch(resolvedOpts);
843927
validateExplicitSessionKeyForDispatch(dispatchOpts);
844928
const gatewayDispatchOpts = dispatchOpts.runId
845929
? dispatchOpts

0 commit comments

Comments
 (0)