fix: keep gateway model probes raw

This commit is contained in:
Peter Steinberger
2026-04-28 09:08:07 +01:00
parent bce6c10290
commit fb3ea9efb1
15 changed files with 455 additions and 139 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby.
- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar.
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
- CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer.
- CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge.
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.

View File

@@ -131,7 +131,7 @@ This table maps common inference tasks to the corresponding infer command.
- Gateway-managed state commands default to gateway.
- The normal local path does not require the gateway to be running.
- Local `model run` is a lean one-shot provider completion. It resolves the configured agent model and auth, but does not start a chat-agent turn, load tools, or open bundled MCP servers.
- `model run --gateway` still uses the Gateway agent runtime so it can exercise the same routed runtime path as a normal Gateway-backed turn. MCP servers opened through that runtime are retired after the reply, so repeated scripted invocations do not keep stdio MCP child processes alive.
- `model run --gateway` exercises Gateway routing, saved auth, provider selection, and the embedded runtime, but still runs as a raw model probe: it sends the supplied prompt without prior session transcript, bootstrap/AGENTS context, context-engine assembly, tools, or bundled MCP servers.
## Model
@@ -161,7 +161,7 @@ Notes:
- Local `model run` is the narrowest CLI smoke for provider/model/auth health because it sends only the supplied prompt to the selected model.
- `model run --prompt` must contain non-whitespace text; empty prompts are rejected before local providers or the Gateway are called.
- Local `model run` exits non-zero when the provider returns no text output, so unreachable local providers and empty completions do not look like successful probes.
- Use `model run --gateway` when you need to test Gateway routing, agent-runtime setup, or Gateway-managed provider state instead of the lean local completion path.
- Use `model run --gateway` when you need to test Gateway routing, agent-runtime setup, or Gateway-managed provider state while keeping the model input raw. Use `openclaw agent` or chat surfaces when you want the full agent context, tools, memory, and session transcript.
- `model auth login`, `model auth logout`, and `model auth status` manage saved provider auth state.
## Image

View File

@@ -253,6 +253,18 @@ Compatibility notes for stricter OpenAI-compatible backends:
openclaw infer model run --local --model <provider/model> --prompt "Reply with exactly: pong" --json
```
To verify the Gateway route without the full agent prompt shape, use the
Gateway model probe instead:
```bash
openclaw infer model run --gateway --model <provider/model> --prompt "Reply with exactly: pong" --json
```
Both local and Gateway model probes send only the supplied prompt. The
Gateway probe still validates Gateway routing, auth, and provider selection,
but it intentionally skips prior session transcript, AGENTS/bootstrap context,
context-engine assembly, tools, and bundled MCP servers.
If that succeeds but normal OpenClaw agent turns fail, first try
`agents.defaults.experimental.localModelLean: true` to drop heavyweight
default tools like `browser`, `cron`, and `message`; this is an experimental

View File

@@ -255,6 +255,7 @@ async function prepareAgentCommandExecution(
opts: AgentCommandOpts & { senderIsOwner: boolean },
runtime: RuntimeEnv,
) {
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
const message = opts.message ?? "";
if (!message.trim()) {
throw new Error("Message (--message) is required");
@@ -377,7 +378,7 @@ async function prepareAgentCommandExecution(
})
: null;
const body =
acpResolution?.kind === "ready"
!isRawModelRun && acpResolution?.kind === "ready"
? resolveAcpPromptBody(message, opts.internalEvents)
: prependInternalEventContext(message, opts.internalEvents);
const transcriptBody =
@@ -417,6 +418,7 @@ async function agentCommandInternal(
deps?: CliDeps,
) {
const resolvedDeps = await resolveAgentCommandDeps(deps);
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
const prepared = await prepareAgentCommandExecution(opts, runtime);
const {
body,
@@ -459,11 +461,11 @@ async function agentCommandInternal(
}
}
if (acpResolution?.kind === "stale") {
if (!isRawModelRun && acpResolution?.kind === "stale") {
throw acpResolution.error;
}
if (acpResolution?.kind === "ready" && sessionKey) {
if (!isRawModelRun && acpResolution?.kind === "ready" && sessionKey) {
const attemptExecutionRuntime = await loadAttemptExecutionRuntime();
const startedAt = Date.now();
registerAgentRunContext(runId, {

View File

@@ -529,6 +529,70 @@ describe("CLI attempt execution", () => {
);
});
it("keeps one-shot model runs on the raw embedded provider path", async () => {
const sessionKey = "agent:main:direct:model-run-raw";
const sessionEntry: SessionEntry = {
sessionId: "openclaw-session-model-run-raw",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
runEmbeddedPiAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedPiRunResult);
await runAgentAttempt({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
cfg: {
agents: {
defaults: {
agentRuntime: { id: "claude-cli", fallback: "none" },
},
},
} as OpenClawConfig,
sessionEntry,
sessionId: sessionEntry.sessionId,
sessionKey,
sessionAgentId: "main",
sessionFile: path.join(tmpDir, "session.jsonl"),
workspaceDir: tmpDir,
body: "raw prompt",
isFallbackRetry: false,
resolvedThinkLevel: "medium",
timeoutMs: 1_000,
runId: "run-model-run-raw",
opts: {
senderIsOwner: false,
modelRun: true,
promptMode: "none",
} as Parameters<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[0]["runContext"],
spawnedBy: undefined,
messageChannel: "telegram",
skillsSnapshot: undefined,
resolvedVerboseLevel: undefined,
agentDir: tmpDir,
onAgentEvent: vi.fn(),
authProfileProvider: "anthropic",
sessionStore,
storePath,
sessionHasHistory: true,
});
expect(runCliAgentMock).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
model: "claude-opus-4-7",
agentHarnessId: "pi",
modelRun: true,
promptMode: "none",
disableTools: true,
}),
);
});
it("forwards one-shot CLI cleanup to CLI providers", async () => {
const sessionKey = "agent:main:direct:cleanup-claude-cli";
const sessionEntry: SessionEntry = {

View File

@@ -261,7 +261,9 @@ export function runAgentAttempt(params: {
allowTransientCooldownProbe?: boolean;
sessionHasHistory?: boolean;
}) {
const isRawModelRun = params.opts.modelRun === true || params.opts.promptMode === "none";
const claudeCliFallbackPrelude =
!isRawModelRun &&
params.isFallbackRetry &&
isClaudeCliProvider(params.originalProvider) &&
!isClaudeCliProvider(params.providerOverride)
@@ -280,29 +282,36 @@ export function runAgentAttempt(params: {
);
const bootstrapPromptWarningSignature =
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
const sessionPinnedAgentHarnessId = resolveSessionPinnedAgentHarnessId({
cfg: params.cfg,
sessionAgentId: params.sessionAgentId,
sessionEntry: params.sessionEntry,
sessionHasHistory: params.sessionHasHistory,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const agentRuntimeOverride = params.sessionEntry?.agentRuntimeOverride?.trim();
const cliExecutionProvider =
resolveCliRuntimeExecutionProvider({
provider: params.providerOverride,
cfg: params.cfg,
agentId: params.sessionAgentId,
runtimeOverride: agentRuntimeOverride,
}) ?? params.providerOverride;
const agentHarnessPolicy = resolveAgentHarnessPolicy({
provider: params.providerOverride,
modelId: params.modelOverride,
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const sessionPinnedAgentHarnessId = isRawModelRun
? "pi"
: resolveSessionPinnedAgentHarnessId({
cfg: params.cfg,
sessionAgentId: params.sessionAgentId,
sessionEntry: params.sessionEntry,
sessionHasHistory: params.sessionHasHistory,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const agentRuntimeOverride = isRawModelRun
? undefined
: params.sessionEntry?.agentRuntimeOverride?.trim();
const cliExecutionProvider = isRawModelRun
? params.providerOverride
: (resolveCliRuntimeExecutionProvider({
provider: params.providerOverride,
cfg: params.cfg,
agentId: params.sessionAgentId,
runtimeOverride: agentRuntimeOverride,
}) ?? params.providerOverride);
const agentHarnessPolicy = isRawModelRun
? ({ runtime: "pi", fallback: "pi" } as const)
: resolveAgentHarnessPolicy({
provider: params.providerOverride,
modelId: params.modelOverride,
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey ?? params.sessionId,
});
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
provider: params.providerOverride,
authProfileProvider: params.authProfileProvider,
@@ -314,7 +323,7 @@ export function runAgentAttempt(params: {
allowHarnessAuthProfileForwarding: !isCliProvider(cliExecutionProvider, params.cfg),
});
const authProfileId = runtimeAuthPlan.forwardedAuthProfileId;
if (isCliProvider(cliExecutionProvider, params.cfg)) {
if (!isRawModelRun && isCliProvider(cliExecutionProvider, params.cfg)) {
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, cliExecutionProvider);
const resolveReusableCliSessionBinding = async () => {
if (

View File

@@ -82,4 +82,22 @@ describe("runEmbeddedPiAgent cron before_agent_reply seam", () => {
expect(mockedGlobalHookRunner.runBeforeAgentReply).not.toHaveBeenCalled();
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
});
it("forwards one-shot model-run flags into the embedded attempt", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
await runEmbeddedPiAgent({
...overflowBaseRunParams,
trigger: "user",
modelRun: true,
promptMode: "none",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
expect.objectContaining({
modelRun: true,
promptMode: "none",
}),
);
});
});

View File

@@ -981,6 +981,8 @@ export async function runEmbeddedPiAgent(
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
inputProvenance: params.inputProvenance,
streamParams: params.streamParams,
modelRun: params.modelRun,
promptMode: params.promptMode,
ownerNumbers: params.ownerNumbers,
enforceFinalTag: params.enforceFinalTag,
silentExpected: params.silentExpected,

View File

@@ -130,7 +130,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
});
it("sends transcriptPrompt visibly and queues runtime context as hidden custom context", async () => {
const seen: { prompt?: string; messages?: unknown[] } = {};
const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {};
const result = await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
@@ -240,6 +240,72 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
expect(contextCompiled?.data?.systemPrompt).toContain("internal heartbeat event");
});
it("keeps gateway model runs independent from agent context and session history", async () => {
const bootstrap = vi.fn(async () => ({ bootstrapped: true }));
const assemble = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => ({
messages: [
...messages,
{ role: "custom", customType: "test-context", content: "should not be sent" },
] as AgentMessage[],
estimatedTokens: 1,
}));
const afterTurn = vi.fn(async () => {});
const runBeforePromptBuild = vi.fn(async () => ({ prependContext: "hook context" }));
const runLlmInput = vi.fn(async () => {});
hoisted.getGlobalHookRunnerMock.mockReturnValue({
hasHooks: vi.fn(
(name: string) =>
name === "before_prompt_build" || name === "before_agent_start" || name === "llm_input",
),
runBeforePromptBuild,
runBeforeAgentStart: vi.fn(async () => ({ prependContext: "legacy hook context" })),
runLlmInput,
});
const seen: { prompt?: string; messages?: unknown[] } = {};
const result = await createContextEngineAttemptRunner({
contextEngine: createTestContextEngine({
bootstrap,
assemble,
afterTurn,
}),
sessionKey,
tempPaths,
sessionMessages: [
{ role: "user", content: "old session question", timestamp: 1 },
{ role: "assistant", content: "old session answer", timestamp: 2 },
] as AgentMessage[],
attemptOverrides: {
promptMode: "none",
disableTools: true,
},
sessionPrompt: async (session, prompt) => {
seen.prompt = prompt;
seen.messages = [...session.messages];
seen.systemPrompt = session.agent.state.systemPrompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "pong", timestamp: 3 },
];
},
});
expect(seen.prompt).toBe("hello");
expect(seen.messages).toEqual([]);
expect(seen.systemPrompt ?? "").toBe("");
expect(result.finalPromptText).toBe("hello");
expect(result.systemPromptReport?.systemPrompt ?? "").toBe("");
expect(result.messagesSnapshot).toEqual([
expect.objectContaining({ role: "assistant", content: "pong" }),
]);
expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled();
expect(bootstrap).not.toHaveBeenCalled();
expect(assemble).not.toHaveBeenCalled();
expect(afterTurn).not.toHaveBeenCalled();
expect(runBeforePromptBuild).not.toHaveBeenCalled();
expect(runLlmInput).not.toHaveBeenCalled();
});
it("forwards sessionKey to bootstrap, assemble, and afterTurn", async () => {
const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
const afterTurn = vi.fn(async (_params: { sessionKey?: string }) => {});

View File

@@ -679,6 +679,7 @@ export type MutableSession = {
agent: {
streamFn?: unknown;
transport?: string;
reset: () => void;
state: {
messages: unknown[];
systemPrompt?: string;
@@ -798,6 +799,9 @@ export function createDefaultEmbeddedSession(params?: {
isCompacting: false,
isStreaming: false,
agent: {
reset: () => {
session.messages = [];
},
state: {
get messages() {
return session.messages;

View File

@@ -645,6 +645,13 @@ export async function runEmbeddedAttempt(
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextInjectionMode = resolveContextInjectionMode(params.config);
const isRawModelRun = params.modelRun === true || params.promptMode === "none";
if (isRawModelRun && log.isEnabled("debug")) {
log.debug(
`raw model run enabled: modelRun=${params.modelRun === true} promptMode=${params.promptMode ?? "unset"}`,
);
}
const activeContextEngine = isRawModelRun ? undefined : params.contextEngine;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const diagnosticTrace = freezeDiagnosticTraceContext(
createDiagnosticTraceContextFromActiveScope(),
@@ -684,7 +691,7 @@ export async function runEmbeddedAttempt(
});
};
const toolsRaw =
params.disableTools || params.modelRun
params.disableTools || isRawModelRun
? []
: (() => {
const allTools = createOpenClawCodingTools({
@@ -769,7 +776,9 @@ export async function runEmbeddedAttempt(
contextFiles: resolvedContextFiles,
shouldRecordCompletedBootstrapTurn,
} = await resolveAttemptBootstrapContext({
contextInjectionMode,
// modelRun is a provider probe, not an agent turn. Keep AGENTS/BOOTSTRAP
// context out even when the gateway is exercising the embedded runtime.
contextInjectionMode: isRawModelRun ? "never" : contextInjectionMode,
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default",
bootstrapMode,
@@ -864,10 +873,10 @@ export async function runEmbeddedAttempt(
modelApi: params.model.api,
model: params.model,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const clientTools = toolsEnabled && !isRawModelRun ? params.clientTools : undefined;
const bundleMcpEnabled = shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled,
disableTools: params.disableTools,
disableTools: params.disableTools || isRawModelRun,
toolsAllow: params.toolsAllow,
});
const bundleMcpSessionRuntime = bundleMcpEnabled
@@ -887,17 +896,18 @@ export async function runEmbeddedAttempt(
],
})
: undefined;
const bundleLspRuntime = toolsEnabled
? await createBundleLspToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),
...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []),
],
})
: undefined;
const bundleLspRuntime =
toolsEnabled && !isRawModelRun
? await createBundleLspToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),
...(bundleMcpRuntime?.tools.map((tool) => tool.name) ?? []),
],
})
: undefined;
const filteredBundledTools = applyFinalEffectiveToolPolicy({
bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])],
config: params.config,
@@ -1062,7 +1072,7 @@ export async function runEmbeddedAttempt(
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode =
params.promptMode ??
(params.modelRun ? "none" : resolvePromptModeForSession(params.sessionKey));
(isRawModelRun ? "none" : resolvePromptModeForSession(params.sessionKey));
// When toolsAllow is set, use minimal prompt and strip skills catalog
const effectivePromptMode = params.toolsAllow?.length ? ("minimal" as const) : promptMode;
@@ -1148,27 +1158,29 @@ export async function runEmbeddedAttempt(
userTime,
userTimeFormat,
contextFiles,
includeMemorySection: !params.contextEngine || params.contextEngine.info.id === "legacy",
includeMemorySection: !activeContextEngine || activeContextEngine.info.id === "legacy",
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});
const appendPrompt = transformProviderSystemPrompt({
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir: params.agentDir,
workspaceDir: effectiveWorkspace,
provider: params.provider,
modelId: params.modelId,
promptMode: effectivePromptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
systemPrompt: builtAppendPrompt,
},
});
const appendPrompt = isRawModelRun
? ""
: transformProviderSystemPrompt({
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
context: {
config: params.config,
agentDir: params.agentDir,
workspaceDir: effectiveWorkspace,
provider: params.provider,
modelId: params.modelId,
promptMode: effectivePromptMode,
runtimeChannel,
runtimeCapabilities,
agentId: sessionAgentId,
systemPrompt: builtAppendPrompt,
},
});
const systemPromptReport = buildSystemPromptReport({
source: "run",
generatedAt: Date.now(),
@@ -1258,7 +1270,7 @@ export async function runEmbeddedAttempt(
await runAttemptContextEngineBootstrap({
hadSessionFile,
contextEngine: params.contextEngine,
contextEngine: activeContextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
@@ -1298,7 +1310,7 @@ export async function runEmbeddedAttempt(
});
applyPiAutoCompactionGuard({
settingsManager,
contextEngineInfo: params.contextEngine?.info,
contextEngineInfo: activeContextEngine?.info,
});
// Sets compaction/pruning runtime state and returns extension factories
@@ -1420,6 +1432,15 @@ export async function runEmbeddedAttempt(
}
session.setActiveToolsByName(sessionToolAllowlist);
const activeSession = session;
if (isRawModelRun) {
// Raw model probes should measure exactly the requested prompt against
// the selected provider/model. Reset clears restored transcript state
// and queues; the empty system override prevents Pi from rebuilding the
// normal OpenClaw agent/tool prompt when `session.prompt()` starts.
activeSession.agent.reset();
applySystemPromptOverrideToSession(activeSession, "");
systemPromptText = "";
}
if (typeof activeSession.agent.convertToLlm === "function") {
const baseConvertToLlm = activeSession.agent.convertToLlm.bind(activeSession.agent);
activeSession.agent.convertToLlm = async (messages) =>
@@ -1433,7 +1454,7 @@ export async function runEmbeddedAttempt(
queueYieldInterruptForSession = () => {
queueSessionsYieldInterruptMessage(activeSession);
};
if (params.contextEngine?.info?.ownsCompaction !== true) {
if (!activeContextEngine || activeContextEngine.info.ownsCompaction !== true) {
removeToolResultContextGuard = installToolResultContextGuard({
agent: activeSession.agent,
contextWindowTokens: Math.max(
@@ -1446,7 +1467,7 @@ export async function runEmbeddedAttempt(
} else {
removeToolResultContextGuard = installContextEngineLoopHook({
agent: activeSession.agent,
contextEngine: params.contextEngine,
contextEngine: activeContextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
@@ -1867,65 +1888,75 @@ export async function runEmbeddedAttempt(
);
try {
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
allowedToolNames,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
cacheTrace?.recordStage("session:sanitized", { messages: prior });
const validated = await validateReplayTurns({
messages: prior,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const heartbeatSummary =
params.config && sessionAgentId
? resolveHeartbeatSummaryForAgent(params.config, sessionAgentId)
: undefined;
const heartbeatFiltered = filterHeartbeatPairs(
validated,
heartbeatSummary?.ackMaxChars,
heartbeatSummary?.prompt,
);
const truncated = limitHistoryTurns(
heartbeatFiltered,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
// Re-run tool_use/tool_result pairing repair after truncation, since
// limitHistoryTurns can orphan tool_result blocks by removing the
// assistant message that contained the matching tool_use.
const limited = transcriptPolicy.repairToolUseResultPairing
? sanitizeToolUseResultPairing(truncated, {
erroredAssistantResultPolicy: "drop",
...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
})
: truncated;
cacheTrace?.recordStage("session:limited", { messages: limited });
if (limited.length > 0) {
activeSession.agent.state.messages = limited;
if (isRawModelRun) {
activeSession.agent.reset();
applySystemPromptOverrideToSession(activeSession, "");
systemPromptText = "";
cacheTrace?.recordStage("session:raw-model-run", {
messages: activeSession.messages,
system: systemPromptText,
});
} else {
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
allowedToolNames,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
cacheTrace?.recordStage("session:sanitized", { messages: prior });
const validated = await validateReplayTurns({
messages: prior,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const heartbeatSummary =
params.config && sessionAgentId
? resolveHeartbeatSummaryForAgent(params.config, sessionAgentId)
: undefined;
const heartbeatFiltered = filterHeartbeatPairs(
validated,
heartbeatSummary?.ackMaxChars,
heartbeatSummary?.prompt,
);
const truncated = limitHistoryTurns(
heartbeatFiltered,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
// Re-run tool_use/tool_result pairing repair after truncation, since
// limitHistoryTurns can orphan tool_result blocks by removing the
// assistant message that contained the matching tool_use.
const limited = transcriptPolicy.repairToolUseResultPairing
? sanitizeToolUseResultPairing(truncated, {
erroredAssistantResultPolicy: "drop",
...(isOpenAIResponsesApi ? { missingToolResultText: "aborted" } : {}),
})
: truncated;
cacheTrace?.recordStage("session:limited", { messages: limited });
if (limited.length > 0) {
activeSession.agent.state.messages = limited;
}
}
if (params.contextEngine) {
if (activeContextEngine) {
try {
unwindowedContextEngineMessagesForPrecheck = activeSession.messages.slice();
const assembled = await assembleAttemptContextEngine({
contextEngine: params.contextEngine,
contextEngine: activeContextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messages: activeSession.messages,
@@ -2258,14 +2289,16 @@ export async function runEmbeddedAttempt(
};
const promptBuildMessages =
pruneProcessedHistoryImages(activeSession.messages) ?? activeSession.messages;
const hookResult = await resolvePromptBuildHookResult({
config: params.config ?? getRuntimeConfig(),
prompt: params.prompt,
messages: promptBuildMessages,
hookCtx,
hookRunner,
legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult,
});
const hookResult = isRawModelRun
? undefined
: await resolvePromptBuildHookResult({
config: params.config ?? getRuntimeConfig(),
prompt: params.prompt,
messages: promptBuildMessages,
hookCtx,
hookRunner,
legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult,
});
{
if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`;
@@ -2368,7 +2401,7 @@ export async function runEmbeddedAttempt(
});
// Repair orphaned trailing user messages so new prompts don't violate role ordering.
const leafEntry = sessionManager.getLeafEntry();
const leafEntry = isRawModelRun ? null : sessionManager.getLeafEntry();
if (leafEntry?.type === "message" && leafEntry.message.role === "user") {
const orphanPromptMerge = resolveMessageMergeStrategy().mergeOrphanedTrailingUserPrompt({
prompt: effectivePrompt,
@@ -2537,7 +2570,7 @@ export async function runEmbeddedAttempt(
);
}
if (hookRunner?.hasHooks("llm_input")) {
if (!isRawModelRun && hookRunner?.hasHooks("llm_input")) {
hookRunner
.runLlmInput(
{
@@ -2892,7 +2925,7 @@ export async function runEmbeddedAttempt(
}
// Let the active context engine run its post-turn lifecycle.
if (params.contextEngine) {
if (activeContextEngine) {
const afterTurnRuntimeContext = buildAfterTurnRuntimeContextFromUsage({
attempt: params,
workspaceDir: effectiveWorkspace,
@@ -2902,7 +2935,7 @@ export async function runEmbeddedAttempt(
promptCache,
});
await finalizeAttemptContextEngineTurn({
contextEngine: params.contextEngine,
contextEngine: activeContextEngine,
promptError: Boolean(promptError),
aborted,
yieldAborted,

View File

@@ -22,13 +22,24 @@ vi.mock("../cli/deps.js", () => ({
createDefaultDeps: vi.fn(() => ({})),
}));
const acpManagerMock = vi.hoisted(() => ({
current: {
resolveSession: vi.fn(() => null),
} as unknown,
}));
vi.mock("../acp/control-plane/manager.js", () => ({
__testing: {
resetAcpSessionManagerForTests: vi.fn(),
resetAcpSessionManagerForTests: vi.fn(() => {
acpManagerMock.current = {
resolveSession: vi.fn(() => null),
};
}),
setAcpSessionManagerForTests: vi.fn((manager: unknown) => {
acpManagerMock.current = manager;
}),
},
getAcpSessionManager: vi.fn(() => ({
resolveSession: vi.fn(() => null),
})),
getAcpSessionManager: vi.fn(() => acpManagerMock.current),
}));
vi.mock("../agents/pi-embedded.js", () => ({

View File

@@ -440,6 +440,60 @@ describe("agentCommand", () => {
});
});
it("bypasses ACP sessions for one-shot model runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:main";
mockConfig(home, store, { models: {} });
writeSessionStoreSeed(store, {
[sessionKey]: {
sessionId: "acp-backed-session",
updatedAt: Date.now(),
},
});
const runTurn = vi.fn();
acpManagerTesting.setAcpSessionManagerForTests({
resolveSession: vi.fn(() => ({
kind: "ready",
sessionKey,
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
})),
runTurn,
});
await agentCommand(
{
message: "Reply with exactly OPENCLAW-MODEL-OK",
sessionKey,
model: "openrouter/auto",
modelRun: true,
promptMode: "none",
},
runtime,
);
expect(runTurn).not.toHaveBeenCalled();
const callArgs = getLastEmbeddedCall();
expect(callArgs).toEqual(
expect.objectContaining({
provider: "openrouter",
model: "openrouter/auto",
prompt: "Reply with exactly OPENCLAW-MODEL-OK",
modelRun: true,
promptMode: "none",
disableTools: true,
}),
);
});
});
it("passes resolved session-id resume files to embedded runs", async () => {
await withTempHome(async (home) => {
const resumeStore = path.join(home, "sessions-resume.json");

View File

@@ -833,6 +833,45 @@ describe("gateway agent handler", () => {
resetTimeConfig();
});
it("keeps model-run gateway prompts undecorated and forwards raw-run flags", async () => {
setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z");
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
await invokeAgent(
{
message: "Reply exactly: pong",
agentId: "main",
provider: "ollama",
model: "llama3.2:latest",
modelRun: true,
promptMode: "none",
sessionKey: "agent:main:main",
idempotencyKey: "test-model-run-raw",
},
{
reqId: "model-run-raw",
client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"],
},
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls[0][0] as {
message?: string;
modelRun?: boolean;
promptMode?: string;
};
expect(callArgs).toEqual(
expect.objectContaining({
message: "Reply exactly: pong",
modelRun: true,
promptMode: "none",
}),
);
resetTimeConfig();
});
it.each([
{
name: "passes senderIsOwner=false for write-scoped gateway callers",

View File

@@ -448,6 +448,7 @@ export const agentHandlers: GatewayRequestHandlers = {
const allowModelOverride = resolveAllowModelOverrideFromClient(client);
const canResetSession = resolveCanResetSessionFromClient(client);
const requestedModelOverride = Boolean(request.provider || request.model);
const isRawModelRun = request.modelRun === true || request.promptMode === "none";
if (requestedModelOverride && !allowModelOverride) {
respond(
false,
@@ -773,7 +774,7 @@ export const agentHandlers: GatewayRequestHandlers = {
// Channel messages (Discord, Telegram, etc.) get timestamps via envelope
// formatting in a separate code path — they never reach this handler.
// See: https://github.com/openclaw/openclaw/issues/3658
if (!skipTimestampInjection) {
if (!skipTimestampInjection && !isRawModelRun) {
message = injectTimestamp(message, timestampOptsFromConfig(cfg));
}