Skip to content

Commit cd05d4a

Browse files
committed
feat(cli): read agent messages from files
1 parent 7975ec0 commit cd05d4a

6 files changed

Lines changed: 180 additions & 15 deletions

File tree

docs/cli/agent.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ Related:
2323

2424
## Options
2525

26-
- `-m, --message <text>`: required message body
26+
- `-m, --message <text>`: message body
27+
- `--message-file <path>`: read the message body from a UTF-8 file
2728
- `-t, --to <dest>`: recipient used to derive the session key
2829
- `--session-key <key>`: explicit session key to use for routing
2930
- `--session-id <id>`: explicit session id
@@ -45,6 +46,7 @@ Related:
4546
```bash
4647
openclaw agent --to +15555550123 --message "status update" --deliver
4748
openclaw agent --agent ops --message "Summarize logs"
49+
openclaw agent --agent ops --message-file ./task.md
4850
openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs"
4951
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
5052
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
@@ -56,6 +58,7 @@ openclaw agent --agent ops --message "Run locally" --local
5658

5759
## Notes
5860

61+
- Pass exactly one of `--message` or `--message-file`. `--message-file` preserves multiline file content after removing an optional UTF-8 BOM, and rejects files that are not valid UTF-8.
5962
- Gateway mode falls back to the embedded agent when the Gateway request fails. Use `--local` to force embedded execution up front.
6063
- `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs.
6164
- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive.

docs/tools/agent-send.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ programmatic delivery.
2222

2323
</Step>
2424

25+
<Step title="Send a multiline prompt from a file">
26+
```bash
27+
openclaw agent --agent ops --message-file ./task.md
28+
```
29+
30+
This reads a valid UTF-8 file as the agent message body.
31+
32+
</Step>
33+
2534
<Step title="Target a specific agent or session">
2635
```bash
2736
# Target a specific agent
@@ -56,7 +65,8 @@ programmatic delivery.
5665

5766
| Flag | Description |
5867
| ----------------------------- | ----------------------------------------------------------- |
59-
| `--message \<text\>` | Message to send (required) |
68+
| `--message \<text\>` | Inline message to send |
69+
| `--message-file \<path\>` | Read the message from a valid UTF-8 file |
6070
| `--to \<dest\>` | Derive session key from a target (phone, chat id) |
6171
| `--session-key \<key\>` | Use an explicit session key |
6272
| `--agent \<id\>` | Target a configured agent (uses its `main` session) |
@@ -76,6 +86,8 @@ programmatic delivery.
7686

7787
- By default, the CLI goes **through the Gateway**. Add `--local` to force the
7888
embedded runtime on the current machine.
89+
- Pass exactly one of `--message` or `--message-file`. File messages preserve
90+
multiline content after removing an optional UTF-8 BOM.
7991
- If the Gateway is unreachable, the CLI **falls back** to the local embedded run.
8092
- Session selection: `--to` derives the session key (group/channel targets
8193
preserve isolation; direct chats collapse to `main`).
@@ -102,6 +114,9 @@ openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
102114
# Turn with thinking level
103115
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
104116

117+
# Multiline prompt from a file
118+
openclaw agent --agent ops --message-file ./task.md
119+
105120
# Exact session key
106121
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
107122

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ 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 agent message body from a UTF-8 file")
3839
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
3940
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
4041
.option("--session-id <id>", "Use an explicit session id")
@@ -71,6 +72,7 @@ ${theme.heading("Examples:")}
7172
${formatHelpExamples([
7273
['openclaw agent --to +15555550123 --message "status update"', "Start a new session."],
7374
['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."],
75+
["openclaw agent --agent ops --message-file ./task.md", "Read a multiline message file."],
7476
[
7577
'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"',
7678
"Target an exact session key.",

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ describe("agent command registration", () => {
118118
expect(deps).toBeUndefined();
119119
});
120120

121+
it("forwards a message file to the agent command", async () => {
122+
await runCli(["agent", "--message-file", "task.md", "--agent", "ops"]);
123+
124+
const [options, callRuntime, deps] = commandCall(agentCliCommandMock);
125+
expect((options as { message?: string }).message).toBeUndefined();
126+
expect((options as { messageFile?: string }).messageFile).toBe("task.md");
127+
expect((options as { agent?: string }).agent).toBe("ops");
128+
expect(callRuntime).toBe(runtime);
129+
expect(deps).toBeUndefined();
130+
});
131+
121132
it("accepts a model override for one-shot agent runs", async () => {
122133
await runCli(["agent", "--message", "hi", "--agent", "ops", "--model", "openai/gpt-5.4"]);
123134

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

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

311+
it("reads a UTF-8 message file for gateway dispatch", async () => {
312+
await withTempStore(async ({ dir }) => {
313+
const messageFile = path.join(dir, "task.md");
314+
const messageBody = 'first line\n```json\n{"ok":true}\n```\nsecond line\n';
315+
fs.writeFileSync(messageFile, `\uFEFF${messageBody}`, "utf8");
316+
mockGatewaySuccessReply();
317+
318+
await agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime);
319+
320+
expect(callGateway).toHaveBeenCalledTimes(1);
321+
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
322+
const params = requireRecord(request.params, "gateway request params");
323+
expect(params.message).toBe(messageBody);
324+
});
325+
});
326+
327+
it("rejects inline and file messages together", async () => {
328+
await expect(
329+
agentCliCommand(
330+
{ message: "inline", messageFile: "task.md", sessionKey: "agent:main:incident-42" },
331+
runtime,
332+
),
333+
).rejects.toThrow("Use either --message or --message-file, not both");
334+
expect(callGateway).not.toHaveBeenCalled();
335+
});
336+
337+
it("reports missing message files before dispatch", async () => {
338+
await withTempStore(async ({ dir }) => {
339+
const messageFile = path.join(dir, "missing.md");
340+
341+
await expect(
342+
agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime),
343+
).rejects.toThrow("Message file not found");
344+
expect(callGateway).not.toHaveBeenCalled();
345+
});
346+
});
347+
348+
it("rejects message files that are not valid UTF-8", async () => {
349+
await withTempStore(async ({ dir }) => {
350+
const messageFile = path.join(dir, "task.bin");
351+
fs.writeFileSync(messageFile, Buffer.from([0xff]));
352+
353+
await expect(
354+
agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime),
355+
).rejects.toThrow("Message file must be valid UTF-8");
356+
expect(callGateway).not.toHaveBeenCalled();
357+
});
358+
});
359+
311360
it.each(["/new", "/RESET", "/reset check status"] as const)(
312361
"uses backend admin authority for %s gateway commands",
313362
async (message) => {
@@ -1817,6 +1866,26 @@ describe("agentCliCommand", () => {
18171866
});
18181867
}
18191868

1869+
it("rejects /compact from --message-file before any gateway or embedded turn", async () => {
1870+
await withTempStore(async ({ dir }) => {
1871+
const messageFile = path.join(dir, "compact.md");
1872+
fs.writeFileSync(messageFile, "/compact:Keep recent decisions.", "utf8");
1873+
callGateway.mockRejectedValue(createGatewayTimeoutError());
1874+
1875+
await agentCliCommand(
1876+
{ messageFile, sessionId: "locked-session", runId: "locked-run", timeout: "0" },
1877+
runtime,
1878+
);
1879+
});
1880+
1881+
expect(callGateway).not.toHaveBeenCalled();
1882+
expect(agentCommand).not.toHaveBeenCalled();
1883+
expect(runtime.exit).toHaveBeenCalledWith(1);
1884+
const errorMessages = mockMessages(runtime.error);
1885+
expect(errorMessages.some((m) => m.includes("openclaw sessions compact"))).toBe(true);
1886+
expect(errorMessages.some((m) => m.includes("EMBEDDED FALLBACK"))).toBe(false);
1887+
});
1888+
18201889
it("does not mistake a /compacting-prefixed message for the /compact control command", async () => {
18211890
await withTempStore(async () => {
18221891
mockGatewaySuccessReply();

src/commands/agent-via-gateway.ts

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Gateway-first agent CLI implementation with embedded fallback for local/runtime failures.
22
import { randomUUID } from "node:crypto";
3+
import { readFile } from "node:fs/promises";
4+
import { TextDecoder } from "node:util";
35
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
46
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
57
import {
@@ -64,7 +66,8 @@ const GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX = "gateway-fallback-";
6466
const GATEWAY_TRANSIENT_CONNECT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 15_000] as const;
6567

6668
type AgentCliOpts = {
67-
message: string;
69+
message?: string;
70+
messageFile?: string;
6871
agent?: string;
6972
model?: string;
7073
to?: string;
@@ -85,6 +88,9 @@ type AgentCliOpts = {
8588
extraSystemPrompt?: string;
8689
local?: boolean;
8790
};
91+
type AgentDispatchOpts = Omit<AgentCliOpts, "messageFile"> & {
92+
message: string;
93+
};
8894

8995
type AgentCliSignal = "SIGINT" | "SIGTERM";
9096
type AgentCliProcessLike = {
@@ -110,6 +116,7 @@ const AGENT_CLI_SIGNAL_EXIT_CODES: Record<AgentCliSignal, number> = {
110116
SIGINT: 130,
111117
SIGTERM: 143,
112118
};
119+
const MESSAGE_FILE_DECODER = new TextDecoder("utf-8", { fatal: true });
113120

114121
let embeddedAgentCommandPromise: Promise<EmbeddedAgentCommandModule["agentCommand"]> | undefined;
115122
let agentSessionModulePromise: Promise<AgentSessionModule> | undefined;
@@ -172,6 +179,63 @@ function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
172179
}
173180
}
174181

182+
function missingAgentMessageError(): Error {
183+
return new Error(
184+
`Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or ${formatCliCommand("openclaw agent --message-file <path> --agent <id>")}.`,
185+
);
186+
}
187+
188+
function formatMessageFileReadFailure(messageFile: string, err: unknown): string {
189+
const code =
190+
typeof (err as { code?: unknown })?.code === "string" ? (err as { code: string }).code : "";
191+
if (code === "ENOENT") {
192+
return `Message file not found: ${messageFile}`;
193+
}
194+
if (code === "EISDIR") {
195+
return `Message file is a directory: ${messageFile}`;
196+
}
197+
const message = err instanceof Error ? err.message : String(err);
198+
return `Unable to read message file ${messageFile}: ${message}`;
199+
}
200+
201+
async function readAgentMessageFile(messageFile: string): Promise<string> {
202+
let buffer: Buffer;
203+
try {
204+
buffer = await readFile(messageFile);
205+
} catch (err) {
206+
throw new Error(formatMessageFileReadFailure(messageFile, err), { cause: err });
207+
}
208+
try {
209+
return MESSAGE_FILE_DECODER.decode(buffer).replace(/^\uFEFF/, "");
210+
} catch {
211+
throw new Error(`Message file must be valid UTF-8: ${messageFile}`);
212+
}
213+
}
214+
215+
async function resolveAgentMessageOpts(opts: AgentCliOpts): Promise<AgentDispatchOpts> {
216+
const { messageFile: rawMessageFile, ...rest } = opts;
217+
const messageFile = rawMessageFile?.trim();
218+
const hasInlineMessage = opts.message !== undefined;
219+
if (hasInlineMessage && messageFile) {
220+
throw new Error("Use either --message or --message-file, not both.");
221+
}
222+
if (rawMessageFile !== undefined && !messageFile) {
223+
throw new Error("--message-file must not be empty.");
224+
}
225+
if (messageFile) {
226+
const message = await readAgentMessageFile(messageFile);
227+
if (!message.trim()) {
228+
throw new Error(`Message file is empty: ${messageFile}`);
229+
}
230+
return { ...rest, message };
231+
}
232+
const message = (opts.message ?? "").trim();
233+
if (!message) {
234+
throw missingAgentMessageError();
235+
}
236+
return { ...rest, message };
237+
}
238+
175239
function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) {
176240
const raw =
177241
opts.timeout !== undefined
@@ -287,7 +351,9 @@ function validateExplicitSessionKeyForDispatch(
287351
}
288352
}
289353

290-
async function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): Promise<AgentCliOpts> {
354+
async function normalizeSessionKeyOptsForDispatch(
355+
opts: AgentDispatchOpts,
356+
): Promise<AgentDispatchOpts> {
291357
const rawSessionKey = opts.sessionKey?.trim();
292358
const rawTo = opts.to?.trim();
293359
if (!rawSessionKey && !opts.sessionId?.trim() && classifySessionKeyShape(rawTo) === "agent") {
@@ -567,7 +633,7 @@ function createGatewayTimeoutFallbackSession(agentId?: string): {
567633
}
568634

569635
async function resolveAgentIdForGatewayTimeoutFallback(
570-
opts: AgentCliOpts,
636+
opts: AgentDispatchOpts,
571637
): Promise<string | undefined> {
572638
const explicitSessionKey = opts.sessionKey?.trim();
573639
if (classifySessionKeyShape(explicitSessionKey) === "agent") {
@@ -619,17 +685,15 @@ function formatInFlightGatewayAgentMessage(response: GatewayAgentResponse): stri
619685
}
620686

621687
async function agentViaGatewayCommand(
622-
opts: AgentCliOpts,
688+
opts: AgentDispatchOpts,
623689
runtime: RuntimeEnv,
624690
signalBridge: ReturnType<typeof createAgentCliSignalBridge>,
625691
) {
626692
protectJsonStdout(opts);
627-
const body = (opts.message ?? "").trim();
693+
const body = opts.message;
628694
const explicitSessionKey = opts.sessionKey?.trim();
629-
if (!body) {
630-
throw new Error(
631-
`Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or pass --to/--session-key/--session-id for an existing conversation.`,
632-
);
695+
if (!body.trim()) {
696+
throw missingAgentMessageError();
633697
}
634698
if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) {
635699
throw new Error(
@@ -805,7 +869,7 @@ async function agentViaGatewayCommand(
805869
}
806870

807871
async function agentViaGatewayCommandWithTransientRetries(
808-
opts: AgentCliOpts,
872+
opts: AgentDispatchOpts,
809873
runtime: RuntimeEnv,
810874
signalBridge: ReturnType<typeof createAgentCliSignalBridge>,
811875
) {
@@ -838,18 +902,19 @@ export async function agentCliCommand(
838902
deps?: AgentCliDeps,
839903
) {
840904
protectJsonStdout(opts);
905+
const messageOpts = await resolveAgentMessageOpts(opts);
841906
// `/compact` cannot run as a plain CLI agent turn: the slash-command handler
842907
// rejects CLI-originated senders, so the message would fall through to a
843908
// normal turn and exit 0 without compacting anything (issue #90640 Gap B).
844909
// Fail loudly and point at the first-class command instead of no-opping.
845-
if (isCompactControlCommand(opts.message)) {
910+
if (isCompactControlCommand(messageOpts.message)) {
846911
runtime.error?.(
847912
"Slash commands cannot be executed via --message from the CLI. Use: openclaw sessions compact <key>",
848913
);
849914
runtime.exit(1);
850915
return undefined;
851916
}
852-
const dispatchOpts = await normalizeSessionKeyOptsForDispatch(opts);
917+
const dispatchOpts = await normalizeSessionKeyOptsForDispatch(messageOpts);
853918
validateExplicitSessionKeyForDispatch(dispatchOpts);
854919
const gatewayDispatchOpts = dispatchOpts.runId
855920
? dispatchOpts

0 commit comments

Comments
 (0)