fix: keep agent json stdout clean

This commit is contained in:
Peter Steinberger
2026-04-25 04:32:11 +01:00
parent b0709a894d
commit 96515891a2
4 changed files with 70 additions and 1 deletions

View File

@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319.
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.
- Providers/DeepSeek: add missing `reasoning_content` placeholders for replayed assistant tool-call turns when DeepSeek V4 thinking is enabled, so switching an existing session to `deepseek-v4-flash` or `deepseek-v4-pro` no longer trips the provider's 400 replay check. Fixes #71372. Thanks @yangyang1719.
- Exec approvals: allow bare command-name allowlist patterns to match PATH-resolved executable basenames without trusting `./tool` or absolute path-selected binaries. Fixes #71315. Thanks @chen-zhang-cs-code and @dengluozhang.

View File

@@ -53,6 +53,7 @@ openclaw agent --agent ops --message "Run locally" --local
- Gateway mode falls back to the embedded agent when the Gateway request fails. Use `--local` to force embedded execution up front.
- `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs.
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.

View File

@@ -1,8 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loggingState } from "../logging/state.js";
import type { RuntimeEnv } from "../runtime.js";
import { agentCliCommand } from "./agent-via-gateway.js";
import type { agentCommand as AgentCommand } from "./agent.js";
@@ -17,6 +18,14 @@ const runtime: RuntimeEnv = {
exit: vi.fn(),
};
const jsonRuntime = {
log: vi.fn(),
error: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
exit: vi.fn(),
};
function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
loadConfig.mockReturnValue({
agents: {
@@ -76,8 +85,16 @@ vi.mock("../gateway/call.js", () => ({
}));
vi.mock("./agent.js", () => ({ agentCommand }));
let originalForceConsoleToStderr = false;
beforeEach(() => {
vi.clearAllMocks();
originalForceConsoleToStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = false;
});
afterEach(() => {
loggingState.forceConsoleToStderr = originalForceConsoleToStderr;
});
describe("agentCliCommand", () => {
@@ -105,6 +122,28 @@ describe("agentCliCommand", () => {
});
});
it("routes diagnostics to stderr before JSON gateway execution", async () => {
await withTempStore(async () => {
const response = {
runId: "idem-1",
status: "ok",
result: {
payloads: [{ text: "hello" }],
meta: { stub: true },
},
};
callGateway.mockImplementationOnce(async () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
return response;
});
await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
expect(jsonRuntime.writeJson).toHaveBeenCalledWith(response, 2);
expect(jsonRuntime.log).not.toHaveBeenCalled();
});
});
it("falls back to embedded agent when gateway fails", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(new Error("gateway not connected"));
@@ -118,6 +157,25 @@ describe("agentCliCommand", () => {
});
});
it("keeps diagnostics on stderr before JSON embedded fallback", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(new Error("gateway not connected"));
agentCommand.mockImplementationOnce(async (_opts, rt) => {
expect(loggingState.forceConsoleToStderr).toBe(true);
rt?.log?.("local");
return {
payloads: [{ text: "local" }],
meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" } },
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
});
await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(loggingState.forceConsoleToStderr).toBe(true);
});
});
it("skips gateway when --local is set", async () => {
await withTempStore(async () => {
mockLocalAgentReply();

View File

@@ -7,6 +7,7 @@ import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
import { routeLogsToStderr } from "../logging/console.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -53,6 +54,12 @@ export type AgentCliOpts = {
local?: boolean;
};
function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
if (opts.json === true) {
routeLogsToStderr();
}
}
function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) {
const raw =
opts.timeout !== undefined
@@ -85,6 +92,7 @@ function formatPayloadForLog(payload: {
}
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
protectJsonStdout(opts);
const body = (opts.message ?? "").trim();
if (!body) {
throw new Error("Message (--message) is required");
@@ -178,6 +186,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
}
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
protectJsonStdout(opts);
const localOpts = {
...opts,
agentId: opts.agent,