diff --git a/CHANGELOG.md b/CHANGELOG.md index b82288e4f77..aa3b5cb65c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 7691fa32060..f150d7ff3ee 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -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 { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index f8a83a1a47f..6b6db0d7567 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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; diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index fc8341b7163..6f965c1b708 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 3218d996acf..96f1ab6111a 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 7f2b61b5c93..49b88005b61 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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, }); diff --git a/src/auto-reply/reply/prompt-prelude.test.ts b/src/auto-reply/reply/prompt-prelude.test.ts new file mode 100644 index 00000000000..f6e0b3a950b --- /dev/null +++ b/src/auto-reply/reply/prompt-prelude.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/reply/prompt-prelude.ts b/src/auto-reply/reply/prompt-prelude.ts index 65092eadd31..c2fd9aa163c 100644 --- a/src/auto-reply/reply/prompt-prelude.ts +++ b/src/auto-reply/reply/prompt-prelude.ts @@ -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 & { + /** 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, + }; +}