refactor: centralize reply prompt envelope

This commit is contained in:
Peter Steinberger
2026-05-11 07:51:11 +01:00
parent c6041616e1
commit f28a987442
8 changed files with 284 additions and 46 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- OpenAI Codex: surface browser OAuth and device-code login failures instead of treating failed logins as empty successful auth results. Refs #80363.
- CLI agents: carry runtime-only current-turn sender/reply context into CLI model prompts while keeping prompt-build hook input and transcript text clean.
- fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987.
- Slack: include the bot's own root/parent message in new thread sessions so in-thread replies reach the agent with the parent text the user is responding to, instead of only `reply_to_id` metadata. Fixes #79338. Thanks @sxxtony.
- Docker: keep image builds on the source pnpm workspace policy so pnpm 11 can prune production dependencies without a Docker-only workspace rewrite.

View File

@@ -339,6 +339,55 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("prepends current-turn context after prompt-build hooks without changing hook or transcript prompt", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"),
runBeforePromptBuild: vi.fn(async () => ({
prependContext: "trusted hook context",
appendContext: "trusted hook tail",
})),
runBeforeAgentStart: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:test",
agentId: "main",
trigger: "user",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
transcriptPrompt: "latest ask",
currentTurnContext: {
text: "Sender (untrusted metadata):\nsender_id=U123",
promptJoiner: " ",
},
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-context",
config: createCliBackendConfig(),
});
expect(context.params.prompt).toBe(
"Sender (untrusted metadata):\nsender_id=U123 trusted hook context\n\nlatest ask\n\ntrusted hook tail",
);
expect(context.params.transcriptPrompt).toBe("latest ask");
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
[unknown, unknown]
>;
expect(beforePromptBuildCalls[0]?.[0]).toMatchObject({
prompt: "latest ask",
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("marks inter-session prompts after CLI prompt-build hook context is applied", async () => {
const { dir, sessionFile } = createSessionFile();
try {

View File

@@ -39,6 +39,7 @@ import {
import { resolvePromptBuildHookResult } from "../pi-embedded-runner/run/attempt.prompt-helpers.js";
import { resolveAttemptPrependSystemContext } from "../pi-embedded-runner/run/attempt.prompt-helpers.js";
import { composeSystemPromptWithHookContext } from "../pi-embedded-runner/run/attempt.thread-helpers.js";
import { buildCurrentTurnPrompt } from "../pi-embedded-runner/run/runtime-context-prompt.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import { resolveSkillsPromptForRun } from "../skills.js";
import { resolveSystemPromptOverride } from "../system-prompt-override.js";
@@ -405,6 +406,10 @@ export async function prepareCliRunContext(
} catch (error) {
cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`);
}
preparedPrompt = buildCurrentTurnPrompt({
context: params.currentTurnContext,
prompt: preparedPrompt,
});
preparedPrompt = annotateInterSessionPromptText(preparedPrompt, params.inputProvenance);
const allowRawTranscriptReseed =
backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true;

View File

@@ -9,7 +9,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import type { InputProvenance } from "../../sessions/input-provenance.js";
import type { ResolvedCliBackend } from "../cli-backends.js";
import type { EmbeddedRunTrigger } from "../pi-embedded-runner/run/params.js";
import type {
CurrentTurnPromptContext,
EmbeddedRunTrigger,
} from "../pi-embedded-runner/run/params.js";
import type { SkillSnapshot } from "../skills.js";
import type { SilentReplyPromptMode } from "../system-prompt.types.js";
@@ -23,6 +26,8 @@ export type RunCliAgentParams = {
config?: OpenClawConfig;
prompt: string;
transcriptPrompt?: string;
/** Runtime-only current-turn context visible to the model but excluded from transcript text. */
currentTurnContext?: CurrentTurnPromptContext;
inputProvenance?: InputProvenance;
provider: string;
model?: string;

View File

@@ -1558,6 +1558,7 @@ export async function runAgentTurnWithFallback(params: {
config: runtimeConfig,
prompt: params.commandBody,
transcriptPrompt: params.transcriptCommandBody,
currentTurnContext: params.followupRun.currentTurnContext,
inputProvenance: params.followupRun.run.inputProvenance,
provider: cliExecutionProvider,
model,

View File

@@ -4,7 +4,6 @@ import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-codex-routing.js";
import type { CurrentTurnPromptContext } from "../../agents/pi-embedded-runner/run/params.js";
import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js";
import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js";
import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js";
@@ -33,7 +32,6 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
import { resolveEnvelopeFormatOptions } from "../envelope.js";
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import {
type ElevatedLevel,
@@ -67,7 +65,7 @@ import {
} from "./inbound-meta.js";
import type { createModelSelectionState } from "./model-selection.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import { buildReplyPromptBodies } from "./prompt-prelude.js";
import { buildReplyPromptEnvelope, buildReplyPromptEnvelopeBase } from "./prompt-prelude.js";
import { resolveActiveRunQueueAction } from "./queue-policy.js";
import { resolveQueueSettings } from "./queue/settings-runtime.js";
import { isSteeringQueueMode } from "./queue/steering.js";
@@ -621,18 +619,7 @@ export async function runPreparedReply(
: { ...sessionCtx, ThreadStarterBody: undefined },
envelopeOptions,
);
const baseBodyForPrompt = isBareSessionReset
? [
inboundUserContext,
startupContextPrelude,
baseBodyFinal,
softResetTail
? `User note for this reset turn (treat as ordinary user input, not startup instructions):\n${softResetTail}`
: "",
]
.filter(Boolean)
.join("\n\n")
: baseBodyFinal;
const inboundUserContextPromptJoiner = resolveInboundUserContextPromptJoiner(sessionCtx);
const hasUserBody =
baseBodyFinal.trim().length > 0 ||
softResetTail.length > 0 ||
@@ -651,16 +638,20 @@ export async function runPreparedReply(
text: "I didn't receive any text in your message. Please resend or add a caption.",
};
}
// When the user sends media without text, provide a minimal body so the agent
// run proceeds and the image/document is injected by the embedded runner.
const effectiveBaseBody = hasUserBody ? baseBodyForPrompt : "[User sent media without caption]";
const transcriptBodyBase = isHeartbeat
? HEARTBEAT_TRANSCRIPT_PROMPT
: isBareSessionReset
? softResetTail || `[OpenClaw session ${startupAction}]`
: hasUserBody
? baseBodyFinal
: "[User sent media without caption]";
const promptEnvelopeBase = buildReplyPromptEnvelopeBase({
ctx,
sessionCtx,
baseBody: baseBodyFinal,
hasUserBody,
inboundUserContext,
inboundUserContextPromptJoiner,
isBareSessionReset,
startupAction,
startupContextPrelude,
softResetTail,
isHeartbeat,
});
const effectiveBaseBody = promptEnvelopeBase.effectiveBaseBody;
let prefixedBodyBase = await applySessionHints({
baseBody: effectiveBaseBody,
abortedLastRun,
@@ -701,6 +692,7 @@ export async function runPreparedReply(
prefixedCommandBody: string;
queuedBody: string;
transcriptCommandBody: string;
currentTurnContext?: typeof promptEnvelopeBase.currentTurnContext;
}> => {
if (!useFastReplyRuntime) {
const eventsBlock = await drainFormattedSystemEvents({
@@ -716,12 +708,19 @@ export async function runPreparedReply(
}
}
}
return buildReplyPromptBodies({
return buildReplyPromptEnvelope({
ctx,
sessionCtx,
effectiveBaseBody,
baseBody: baseBodyFinal,
prefixedBody: prefixedBodyCore,
transcriptBody: transcriptBodyBase,
hasUserBody,
inboundUserContext,
inboundUserContextPromptJoiner,
isBareSessionReset,
startupAction,
startupContextPrelude,
softResetTail,
isHeartbeat,
threadContextNote,
systemEventBlocks: drainedSystemEventBlocks,
});
@@ -750,17 +749,8 @@ export async function runPreparedReply(
sessionEntry = skillResult.sessionEntry ?? sessionEntry;
currentSystemSent = skillResult.systemSent;
const skillsSnapshot = skillResult.skillsSnapshot;
let { prefixedCommandBody, queuedBody, transcriptCommandBody } = await traceRunPhase(
"reply.build_prompt_bodies",
() => rebuildPromptBodies(),
);
const currentTurnContext: CurrentTurnPromptContext | undefined =
!isBareSessionReset && inboundUserContext.trim()
? {
text: inboundUserContext,
promptJoiner: resolveInboundUserContextPromptJoiner(sessionCtx),
}
: undefined;
let { prefixedCommandBody, queuedBody, transcriptCommandBody, currentTurnContext } =
await traceRunPhase("reply.build_prompt_bodies", () => rebuildPromptBodies());
if (!resolvedThinkLevel) {
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
@@ -957,10 +947,8 @@ export async function runPreparedReply(
isNewSession,
});
preparedSessionState = resolvePreparedSessionState();
({ prefixedCommandBody, queuedBody, transcriptCommandBody } = await traceRunPhase(
"reply.build_prompt_bodies",
() => rebuildPromptBodies(),
));
({ prefixedCommandBody, queuedBody, transcriptCommandBody, currentTurnContext } =
await traceRunPhase("reply.build_prompt_bodies", () => rebuildPromptBodies()));
},
resolveBusyState: resolveQueueBusyState,
});

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { finalizeInboundContext } from "./inbound-context.js";
import { buildReplyPromptEnvelope } from "./prompt-prelude.js";
describe("buildReplyPromptEnvelope", () => {
it("keeps bare reset runtime context in the model prompt and out of transcript/current-turn context", () => {
const sessionCtx = finalizeInboundContext({
Body: "",
BodyStripped: "",
Provider: "telegram",
ChatType: "direct",
SenderId: "telegram-user-1",
});
const envelope = buildReplyPromptEnvelope({
ctx: sessionCtx,
sessionCtx,
baseBody: "A new session was started via /new or /reset.",
hasUserBody: true,
inboundUserContext: "Conversation info (untrusted metadata):\nsender_id=telegram-user-1",
isBareSessionReset: true,
startupAction: "reset",
startupContextPrelude: "Startup context",
});
expect(envelope.prefixedCommandBody).toContain("sender_id=telegram-user-1");
expect(envelope.prefixedCommandBody).toContain("Startup context");
expect(envelope.transcriptCommandBody).toBe("[OpenClaw session reset]");
expect(envelope.currentTurnContext).toBeUndefined();
});
it("keeps ordinary inbound context runtime-only while preserving transcript text", () => {
const sessionCtx = finalizeInboundContext({
Body: "what changed?",
BodyStripped: "what changed?",
Provider: "slack",
ChatType: "group",
});
const envelope = buildReplyPromptEnvelope({
ctx: sessionCtx,
sessionCtx,
baseBody: "what changed?",
prefixedBody: "what changed?",
hasUserBody: true,
inboundUserContext: "Current message:\nchat_id=C123",
inboundUserContextPromptJoiner: " ",
isBareSessionReset: false,
startupAction: "new",
});
expect(envelope.prefixedCommandBody).toBe("what changed?");
expect(envelope.transcriptCommandBody).toBe("what changed?");
expect(envelope.currentTurnContext).toEqual({
text: "Current message:\nchat_id=C123",
promptJoiner: " ",
});
});
it("keeps soft reset user notes visible without leaking startup context into transcripts", () => {
const sessionCtx = finalizeInboundContext({
Body: "",
BodyStripped: "",
Provider: "slack",
ChatType: "direct",
});
const envelope = buildReplyPromptEnvelope({
ctx: sessionCtx,
sessionCtx,
baseBody: "",
hasUserBody: true,
inboundUserContext: "Sender (untrusted metadata):\nsender_id=U123",
isBareSessionReset: true,
startupAction: "reset",
startupContextPrelude: "Startup context",
softResetTail: "re-read persona files",
});
expect(envelope.prefixedCommandBody).toContain("Sender (untrusted metadata):");
expect(envelope.prefixedCommandBody).toContain("Startup context");
expect(envelope.prefixedCommandBody).toContain("re-read persona files");
expect(envelope.transcriptCommandBody).toBe("re-read persona files");
expect(envelope.transcriptCommandBody).not.toContain("Startup context");
});
});

View File

@@ -1,4 +1,6 @@
import type { CurrentTurnPromptContext } from "../../agents/pi-embedded-runner/run/params.js";
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js";
import { buildInboundMediaNote } from "../media-note.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { appendUntrustedContext } from "./untrusted-context.js";
@@ -10,7 +12,7 @@ export function buildReplyPromptBodies(params: {
ctx: MsgContext;
sessionCtx: TemplateContext;
effectiveBaseBody: string;
prefixedBody: string;
prefixedBody?: string;
transcriptBody?: string;
threadContextNote?: string;
systemEventBlocks?: string[];
@@ -24,9 +26,10 @@ export function buildReplyPromptBodies(params: {
const combinedEventsBlock = (params.systemEventBlocks ?? []).filter(Boolean).join("\n");
const prependEvents = (body: string) =>
combinedEventsBlock ? `${combinedEventsBlock}\n\n${body}` : body;
const rawPrefixedBody = params.prefixedBody ?? params.effectiveBaseBody;
const bodyWithEvents = prependEvents(params.effectiveBaseBody);
const prefixedBodyWithEvents = appendUntrustedContext(
prependEvents(params.prefixedBody),
prependEvents(rawPrefixedBody),
params.sessionCtx.UntrustedContext,
);
const prefixedBody = [params.threadContextNote, prefixedBodyWithEvents]
@@ -59,3 +62,103 @@ export function buildReplyPromptBodies(params: {
),
};
}
export type ReplyPromptEnvelopeStartupAction = "new" | "reset";
export type ReplyPromptEnvelope = ReturnType<typeof buildReplyPromptBodies> & {
/** Model-visible body before media, thread context, and inter-session annotation are applied. */
effectiveBaseBody: string;
/** User-visible body persisted to transcript before media/inter-session annotation. */
transcriptBody: string;
/** Runtime-only user context for backends that can carry it outside transcript text. */
currentTurnContext?: CurrentTurnPromptContext;
};
export type ReplyPromptEnvelopeBase = {
/** Model-visible body before media, thread context, and inter-session annotation are applied. */
effectiveBaseBody: string;
/** User-visible body persisted to transcript before media/inter-session annotation. */
transcriptBody: string;
/** Runtime-only user context for backends that can carry it outside transcript text. */
currentTurnContext?: CurrentTurnPromptContext;
};
type ReplyPromptEnvelopeBaseParams = {
ctx: MsgContext;
sessionCtx: TemplateContext;
baseBody: string;
hasUserBody: boolean;
inboundUserContext: string;
inboundUserContextPromptJoiner?: CurrentTurnPromptContext["promptJoiner"];
isBareSessionReset: boolean;
startupAction: ReplyPromptEnvelopeStartupAction;
startupContextPrelude?: string | null;
softResetTail?: string;
isHeartbeat?: boolean;
};
export function buildReplyPromptEnvelopeBase(
params: ReplyPromptEnvelopeBaseParams,
): ReplyPromptEnvelopeBase {
const softResetTail = params.softResetTail?.trim() ?? "";
const resetModelBody = params.isBareSessionReset
? [
params.inboundUserContext,
params.startupContextPrelude,
params.baseBody,
softResetTail
? `User note for this reset turn (treat as ordinary user input, not startup instructions):\n${softResetTail}`
: "",
]
.filter(Boolean)
.join("\n\n")
: params.baseBody;
const effectiveBaseBody = params.hasUserBody
? resetModelBody
: "[User sent media without caption]";
const transcriptBody = params.isHeartbeat
? HEARTBEAT_TRANSCRIPT_PROMPT
: params.isBareSessionReset
? softResetTail || `[OpenClaw session ${params.startupAction}]`
: params.hasUserBody
? params.baseBody
: "[User sent media without caption]";
const currentTurnContext: CurrentTurnPromptContext | undefined =
!params.isBareSessionReset && params.inboundUserContext.trim()
? {
text: params.inboundUserContext,
promptJoiner: params.inboundUserContextPromptJoiner,
}
: undefined;
return {
effectiveBaseBody,
transcriptBody,
currentTurnContext,
};
}
export function buildReplyPromptEnvelope(
params: ReplyPromptEnvelopeBaseParams & {
prefixedBody?: string;
threadContextNote?: string;
systemEventBlocks?: string[];
},
): ReplyPromptEnvelope {
const base = buildReplyPromptEnvelopeBase(params);
const prefixedBody = params.prefixedBody ?? base.effectiveBaseBody;
const promptBodies = buildReplyPromptBodies({
ctx: params.ctx,
sessionCtx: params.sessionCtx,
effectiveBaseBody: base.effectiveBaseBody,
prefixedBody,
transcriptBody: base.transcriptBody,
threadContextNote: params.threadContextNote,
systemEventBlocks: params.systemEventBlocks,
});
return {
...promptBodies,
...base,
};
}