fix(cli): mark embedded agent fallback (#72730)

* fix(cli): mark embedded agent fallback

* refactor(cli): structure embedded fallback metadata

* refactor(cli): move fallback metadata types out of EmbeddedPiRunMeta

---------

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-04-27 22:14:11 +10:00
committed by GitHub
parent bef28fcf1a
commit b1e530b204
7 changed files with 158 additions and 23 deletions

View File

@@ -80,6 +80,9 @@ Docs: https://docs.openclaw.ai
- Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw.
- Agents/Google: strip stale trailing assistant/model prefill turns from Gemini outbound replay so Google Generative AI requests end with a user turn or function response. Follow-up to #72556. Thanks @Veda-openclaw.
- Control UI/Dreaming: require explicit confirmation before applying restart-impacting Dreaming mode changes, with restart warning copy and loading feedback. Fixes #63804. (#63807) Thanks @bbddbb1.
- CLI/agent: mark Gateway-to-embedded fallback runs with `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` in JSON output, and make the terminal diagnostic explicit so scripts and operators can distinguish fallback runs from Gateway runs. Fixes #71416. Thanks @amknight.
- Agents/tools: normalize `null` or missing tool-call arguments to `{}` for parameterless object schemas before Pi validation, so empty-argument tools run instead of failing argument validation. Fixes #72587. Thanks @amknight.
- Agents/subagents: clear active embedded-run state before terminal lifecycle events so post-completion cleanup no longer treats finished child runs as still active and skips archive or announcement bookkeeping. (#70187) Thanks @amknight.
- CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding `.24` update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin.
- Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock `ContentBlock` blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu.
- Agents/LM Studio: promote standalone bracketed local-model tool requests into registered tool calls and hide unsupported bracket blocks from visible replies, so MemPalace MCP lookups do not print raw `[tool]` JSON scaffolding in chat. Fixes #66178. Thanks @detroit357.
@@ -154,7 +157,7 @@ Docs: https://docs.openclaw.ai
- Subagents: keep the delegated task only in the subagent system prompt and send a short initial kickoff message, avoiding duplicate task tokens while preserving multiline task formatting. Fixes #72019; carries forward #72053. Thanks @Wizongod and @ly85206559.
- Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.
- Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc.
- Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @1fanwang and @amknight.
- Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @amknight.
- macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius.
- Control UI/update: make `Update now` require a real gateway process replacement, report skipped/error update outcomes with stable reasons, and verify the running gateway version after restart so global installs cannot silently keep old code in memory. Fixes #62492; addresses #64892 and #63562. Thanks @IAMSamuelRodda.
- Exec approvals: accept runtime-owned `source: "allow-always"` and `commandText` allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with `unexpected property 'source'`. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang.

View File

@@ -57,6 +57,7 @@ openclaw agent --agent ops --message "Run locally" --local
- Each `openclaw agent` invocation is treated as a one-shot run. Bundled or user-configured MCP servers opened for that run are retired after the reply, even when the command uses the Gateway path, so stdio MCP child processes do not stay alive between scripted invocations.
- `--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.
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs.
- 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

@@ -263,4 +263,48 @@ describe("normalizeAgentCommandReplyPayloads", () => {
},
]);
});
it("merges result metadata overrides into JSON output and returned results", async () => {
const runtime = {
log: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
};
const delivered = await deliverAgentCommandResult({
cfg: {} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "test",
json: true,
resultMetaOverrides: {
transport: "embedded",
fallbackFrom: "gateway",
},
} as AgentCommandOpts,
outboundSession: undefined,
sessionEntry: undefined,
payloads: [{ text: "local" }],
result: createResult(),
});
expect(runtime.log).not.toHaveBeenCalled();
expect(runtime.writeJson).toHaveBeenCalledWith(
{
payloads: [{ text: "local", mediaUrl: null }],
meta: {
durationMs: 1,
transport: "embedded",
fallbackFrom: "gateway",
},
},
2,
);
expect(delivered.meta).toMatchObject({
durationMs: 1,
transport: "embedded",
fallbackFrom: "gateway",
});
});
});

View File

@@ -23,10 +23,11 @@ import {
projectOutboundPayloadPlanForOutbound,
} from "../../infra/outbound/payloads.js";
import type { OutboundSessionContext } from "../../infra/outbound/session-context.js";
import type { RuntimeEnv } from "../../runtime.js";
import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { isNestedAgentLane } from "../lanes.js";
import type { AgentCommandOpts } from "./types.js";
import type { EmbeddedPiRunMeta } from "../pi-embedded-runner/types.js";
import type { AgentCommandOpts, AgentCommandResultMetaOverrides } from "./types.js";
type RunResult = Awaited<ReturnType<(typeof import("../pi-embedded.js"))["runEmbeddedPiAgent"]>>;
@@ -69,6 +70,19 @@ function logNestedOutput(
}
}
function mergeResultMetaOverrides(
meta: EmbeddedPiRunMeta,
overrides: AgentCommandResultMetaOverrides | undefined,
): EmbeddedPiRunMeta & AgentCommandResultMetaOverrides {
if (!overrides) {
return meta;
}
return {
...meta,
...overrides,
};
}
async function normalizeReplyMediaPathsForDelivery(params: {
cfg: OpenClawConfig;
payloads: ReplyPayload[];
@@ -321,25 +335,23 @@ export async function deliverAgentCommandResult(params: {
: normalizedReplyPayloads;
const outboundPayloadPlan = createOutboundPayloadPlan(mediaNormalizedReplyPayloads);
const normalizedPayloads = projectOutboundPayloadPlanForJson(outboundPayloadPlan);
const resultMeta = mergeResultMetaOverrides(result.meta, opts.resultMetaOverrides);
if (opts.json) {
runtime.log(
JSON.stringify(
buildOutboundResultEnvelope({
payloads: normalizedPayloads,
meta: result.meta,
}),
null,
2,
),
writeRuntimeJson(
runtime,
buildOutboundResultEnvelope({
payloads: normalizedPayloads,
meta: resultMeta,
}),
);
if (!deliver) {
return { payloads: normalizedPayloads, meta: result.meta };
return { payloads: normalizedPayloads, meta: resultMeta };
}
}
if (!payloads || payloads.length === 0) {
runtime.log("No reply from agent.");
return { payloads: [], meta: result.meta };
return { payloads: [], meta: resultMeta };
}
const deliveryPayloads = projectOutboundPayloadPlanForOutbound(outboundPayloadPlan);
@@ -381,5 +393,5 @@ export async function deliverAgentCommandResult(params: {
}
}
return { payloads: normalizedPayloads, meta: result.meta };
return { payloads: normalizedPayloads, meta: resultMeta };
}

View File

@@ -14,6 +14,11 @@ export type ImageContent = {
};
export type { AgentStreamParams } from "./shared-types.js";
export type AgentCommandResultMetaOverrides = {
transport?: "embedded";
fallbackFrom?: "gateway";
};
export type AgentRunContext = {
messageChannel?: string;
accountId?: string;
@@ -94,6 +99,8 @@ export type AgentCommandOpts = {
workspaceDir?: SpawnedRunMetadata["workspaceDir"];
/** Force bundled MCP teardown when a one-shot local run completes. */
cleanupBundleMcpOnRunEnd?: boolean;
/** Internal local CLI callers can annotate result metadata before JSON/text output. */
resultMetaOverrides?: AgentCommandResultMetaOverrides;
/** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */
modelRun?: boolean;
/** Internal prompt-mode override for trusted local/gateway callsites. */
@@ -102,7 +109,7 @@ export type AgentCommandOpts = {
export type AgentCommandIngressOpts = Omit<
AgentCommandOpts,
"senderIsOwner" | "allowModelOverride"
"senderIsOwner" | "allowModelOverride" | "resultMetaOverrides"
> & {
/** Ingress callsites must always pass explicit owner-tool authorization state. */
senderIsOwner: boolean;

View File

@@ -173,26 +173,80 @@ describe("agentCliCommand", () => {
expect(callGateway).toHaveBeenCalledTimes(1);
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({
resultMetaOverrides: {
transport: "embedded",
fallbackFrom: "gateway",
},
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("EMBEDDED FALLBACK: Gateway agent failed"),
);
expect(runtime.log).toHaveBeenCalledWith("local");
});
});
it("keeps diagnostics on stderr before JSON embedded fallback", async () => {
it("passes fallback metadata into JSON embedded fallback output", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(new Error("gateway not connected"));
agentCommand.mockImplementationOnce(async (_opts, rt) => {
agentCommand.mockImplementationOnce(async (opts, rt) => {
expect(loggingState.forceConsoleToStderr).toBe(true);
rt?.log?.("local");
const resultMetaOverrides = (
opts as {
resultMetaOverrides?: { transport?: string; fallbackFrom?: string };
}
).resultMetaOverrides;
const meta = {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
...resultMetaOverrides,
};
rt?.log?.(
JSON.stringify(
{
payloads: [{ text: "local" }],
meta,
},
null,
2,
),
);
return {
payloads: [{ text: "local" }],
meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" } },
meta,
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
});
await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
const result = await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({
resultMetaOverrides: {
transport: "embedded",
fallbackFrom: "gateway",
},
});
expect(jsonRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("EMBEDDED FALLBACK: Gateway agent failed"),
);
expect(loggingState.forceConsoleToStderr).toBe(true);
expect(jsonRuntime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(jsonRuntime.log.mock.calls[0]?.[0]));
expect(payload).toMatchObject({
payloads: [{ text: "local" }],
meta: {
durationMs: 1,
transport: "embedded",
fallbackFrom: "gateway",
},
});
expect(result).toMatchObject({
meta: {
durationMs: 1,
transport: "embedded",
fallbackFrom: "gateway",
},
});
});
});
@@ -214,6 +268,7 @@ describe("agentCliCommand", () => {
expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({
cleanupBundleMcpOnRunEnd: true,
});
expect(agentCommand.mock.calls[0]?.[0]).not.toHaveProperty("resultMetaOverrides");
expect(runtime.log).toHaveBeenCalledWith("local");
});
});

View File

@@ -32,6 +32,10 @@ type GatewayAgentResponse = {
};
const NO_GATEWAY_TIMEOUT_MS = 2_147_000_000;
const EMBEDDED_FALLBACK_META = {
transport: "embedded",
fallbackFrom: "gateway",
} as const;
export type AgentCliOpts = {
message: string;
@@ -203,7 +207,16 @@ export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, d
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
return await agentCommand(localOpts, runtime, deps);
runtime.error?.(
`EMBEDDED FALLBACK: Gateway agent failed; running embedded agent: ${String(err)}`,
);
return await agentCommand(
{
...localOpts,
resultMetaOverrides: EMBEDDED_FALLBACK_META,
},
runtime,
deps,
);
}
}