diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index bea4d955cf8..a87d3ddc8da 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -361,9 +361,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }, ); expect(seen.systemPrompt).not.toContain("secret runtime context"); - expect(JSON.stringify(seen.messages)).not.toContain( - "visible ask", - ); + expect(JSON.stringify(seen.messages)).not.toContain("visible ask"); const trajectoryEvents = ( await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8") ) @@ -730,14 +728,23 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }); }); - it("keeps before_prompt_build prependContext out of post-user transcript messages", async () => { - const runBeforePromptBuild = vi.fn(async () => ({ prependContext: "dynamic hook context" })); + it("keeps before_prompt_build context in the model prompt and out of transcript messages", async () => { + const runBeforePromptBuild = vi.fn(async () => ({ + prependContext: "dynamic hook context", + appendContext: "dynamic hook tail", + })); hoisted.getGlobalHookRunnerMock.mockReturnValue({ hasHooks: vi.fn((name: string) => name === "before_prompt_build"), runBeforePromptBuild, runBeforeAgentStart: vi.fn(), }); - const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {}; + const seen: { + modelMessages?: unknown[]; + preprocessedModelMessages?: unknown[]; + prompt?: string; + messages?: unknown[]; + systemPrompt?: string; + } = {}; const result = await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -751,6 +758,21 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { seen.prompt = prompt; seen.messages = [...session.messages]; seen.systemPrompt = session.agent.state.systemPrompt; + const transformContext = ( + session.agent as { + transformContext?: (messages: AgentMessage[]) => Promise; + } + ).transformContext; + seen.modelMessages = await transformContext?.([ + { role: "user", content: [{ type: "text", text: prompt }], timestamp: 1 }, + ]); + seen.preprocessedModelMessages = await transformContext?.([ + { + role: "user", + content: [{ type: "text", text: `session preprocessed\n\n${prompt}` }], + timestamp: 1, + }, + ]); session.messages = [ ...session.messages, { role: "assistant", content: "done", timestamp: 2 }, @@ -760,25 +782,120 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(seen.prompt).toBe("visible ask"); expect(result.finalPromptText).toBe("visible ask"); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook tail"); + expect(JSON.stringify(seen.preprocessedModelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seen.preprocessedModelMessages)).toContain("session preprocessed"); + expect(JSON.stringify(seen.preprocessedModelMessages)).toContain("dynamic hook tail"); expect(seen.systemPrompt).not.toContain("dynamic hook context"); - expectFields( - findRecord( - requireRecords(seen.messages, "seen messages"), - (message) => message.customType === "openclaw.runtime-context", - "hook runtime context message", - ), - { - role: "custom", - customType: "openclaw.runtime-context", - display: false, - content: [ - "OpenClaw runtime context for the immediately preceding user message.", - "This context is runtime-generated, not user-authored. Keep internal details private.", - "", - "dynamic hook context", - ].join("\n"), + expect(seen.systemPrompt).not.toContain("dynamic hook tail"); + expect(JSON.stringify(seen.messages)).not.toContain("dynamic hook context"); + expect(JSON.stringify(seen.messages)).not.toContain("dynamic hook tail"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("dynamic hook context"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("dynamic hook tail"); + }); + + it("keeps hook context model-only when orphan repair merges the prompt", async () => { + const runBeforePromptBuild = vi.fn(async () => ({ + prependContext: "dynamic hook context", + appendContext: "dynamic hook tail", + })); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((name: string) => name === "before_prompt_build"), + runBeforePromptBuild, + runBeforeAgentStart: vi.fn(), + }); + hoisted.sessionManager.getLeafEntry.mockReturnValueOnce({ + id: "orphan-leaf", + parentId: "parent-leaf", + type: "message", + message: { role: "user", content: "orphaned ask", timestamp: 1 }, + }); + const seen: { modelMessages?: unknown[]; prompt?: string; messages?: unknown[] } = {}; + + const result = await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + prompt: "visible ask", }, + sessionPrompt: async (session, prompt) => { + seen.prompt = prompt; + seen.messages = [...session.messages]; + const transformContext = ( + session.agent as { + transformContext?: (messages: AgentMessage[]) => Promise; + } + ).transformContext; + seen.modelMessages = await transformContext?.([ + { role: "user", content: [{ type: "text", text: prompt }], timestamp: 1 }, + ]); + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(seen.prompt).toContain("orphaned ask"); + expect(seen.prompt).toContain("visible ask"); + expect(seen.prompt).not.toContain("dynamic hook context"); + expect(seen.prompt).not.toContain("dynamic hook tail"); + expect(result.finalPromptText).toBe(seen.prompt); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seen.modelMessages)).toContain("orphaned ask"); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook tail"); + expect(JSON.stringify(seen.messages)).not.toContain("dynamic hook context"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("dynamic hook tail"); + expect(hoisted.sessionManager.branch).toHaveBeenCalledWith("parent-leaf"); + }); + + it("keeps hidden runtime context hidden when orphan repair merges a transcript prompt", async () => { + hoisted.sessionManager.getLeafEntry.mockReturnValueOnce({ + id: "orphan-leaf", + parentId: "parent-leaf", + type: "message", + message: { role: "user", content: "orphaned ask", timestamp: 1 }, + }); + const seen: { prompt?: string; messages?: unknown[] } = {}; + + const result = await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + attemptOverrides: { + prompt: [ + "visible ask", + "", + "<<>>", + "secret runtime context", + "<<>>", + ].join("\n"), + transcriptPrompt: "visible ask", + }, + sessionPrompt: async (session, prompt) => { + seen.prompt = prompt; + seen.messages = [...session.messages]; + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(seen.prompt).toContain("orphaned ask"); + expect(seen.prompt).toContain("visible ask"); + expect(seen.prompt).not.toContain("secret runtime context"); + expect(result.finalPromptText).toBe(seen.prompt); + const runtimeContext = findRecord( + requireRecords(seen.messages, "seen messages"), + (message) => message.customType === "openclaw.runtime-context", + "runtime context message", ); + expect(runtimeContext.content).toContain("secret runtime context"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("secret runtime context"); + expect(hoisted.sessionManager.branch).toHaveBeenCalledWith("parent-leaf"); }); it("keeps bootstrap truncation warnings out of WebChat runtime context", async () => { @@ -1035,8 +1152,22 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(promptSubmitted?.data?.prompt).not.toContain("secret runtime context"); }); - it("keeps inter-session provenance hidden while submitting the visible prompt", async () => { - const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {}; + it("keeps hook prompt context visible while hiding inter-session provenance", async () => { + const runBeforePromptBuild = vi.fn(async () => ({ + prependContext: "dynamic hook context", + appendContext: "dynamic hook tail", + })); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((name: string) => name === "before_prompt_build"), + runBeforePromptBuild, + runBeforeAgentStart: vi.fn(), + }); + const seen: { + modelMessages?: unknown[]; + prompt?: string; + messages?: unknown[]; + systemPrompt?: string; + } = {}; const result = await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -1061,6 +1192,14 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { seen.prompt = prompt; seen.messages = [...session.messages]; seen.systemPrompt = session.agent.state.systemPrompt; + const transformContext = ( + session.agent as { + transformContext?: (messages: AgentMessage[]) => Promise; + } + ).transformContext; + seen.modelMessages = await transformContext?.([ + { role: "user", content: [{ type: "text", text: prompt }], timestamp: 1 }, + ]); session.messages = [ ...session.messages, { role: "assistant", content: "done", timestamp: 2 }, @@ -1070,9 +1209,15 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(seen.prompt).toBe("visible ask"); expect(result.finalPromptText).toBe("visible ask"); + expect(seen.prompt).not.toContain("[Inter-session message]"); + expect(seen.prompt).not.toContain("secret runtime context"); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seen.modelMessages)).toContain("dynamic hook tail"); + expect(JSON.stringify(seen.modelMessages)).not.toContain("[Inter-session message]"); + expect(JSON.stringify(seen.modelMessages)).not.toContain("secret runtime context"); const runtimeContext = findRecord( requireRecords(seen.messages, "seen messages"), - (message) => message.customType === "openclaw.runtime-context", + (message) => message.customType === "openclaw.runtime-context", "runtime context message", ); expect(seen.systemPrompt).not.toContain("[Inter-session message]"); @@ -1080,10 +1225,24 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(runtimeContext.content).toContain("isUser=false"); expect(runtimeContext.content).not.toContain("visible ask"); expect(runtimeContext.content).toContain("secret runtime context"); + expect(runtimeContext.content).not.toContain("dynamic hook context"); + expect(runtimeContext.content).not.toContain("dynamic hook tail"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("dynamic hook context"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("dynamic hook tail"); }); it("submits runtime-only context through system prompt without visible prompt", async () => { let seenPrompt: string | undefined; + let seenModelMessages: unknown[] | undefined; + const runBeforePromptBuild = vi.fn(async () => ({ + prependContext: "dynamic hook context", + appendContext: "dynamic hook tail", + })); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((name: string) => name === "before_prompt_build"), + runBeforePromptBuild, + runBeforeAgentStart: vi.fn(), + }); const result = await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -1096,6 +1255,14 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }, sessionPrompt: async (session, prompt) => { seenPrompt = prompt; + const transformContext = ( + session.agent as { + transformContext?: (messages: AgentMessage[]) => Promise; + } + ).transformContext; + seenModelMessages = await transformContext?.([ + { role: "user", content: [{ type: "text", text: prompt }], timestamp: 1 }, + ]); session.messages = [ ...session.messages, { role: "assistant", content: "done", timestamp: 2 }, @@ -1105,6 +1272,9 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(seenPrompt).toBe("Continue the OpenClaw runtime event."); expect(result.finalPromptText).toBe("Continue the OpenClaw runtime event."); + expect(JSON.stringify(seenModelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seenModelMessages)).toContain("internal heartbeat event"); + expect(JSON.stringify(seenModelMessages)).toContain("dynamic hook tail"); expect( requireRecords(result.messagesSnapshot, "messages snapshot").some( (message) => @@ -1118,8 +1288,62 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { .split("\n") .map((line) => JSON.parse(line) as TrajectoryEvent); const contextCompiled = trajectoryEvents.find((event) => event.type === "context.compiled"); - expect(contextCompiled?.data?.prompt).toBe("Continue the OpenClaw runtime event."); + expect(contextCompiled?.data?.prompt).toContain("dynamic hook context"); + expect(contextCompiled?.data?.prompt).toContain("internal heartbeat event"); + expect(contextCompiled?.data?.prompt).toContain("dynamic hook tail"); expect(contextCompiled?.data?.systemPrompt).toContain("internal heartbeat event"); + expect(contextCompiled?.data?.systemPrompt).not.toContain("dynamic hook context"); + expect(contextCompiled?.data?.systemPrompt).not.toContain("dynamic hook tail"); + }); + + it("keeps runtime-only context hidden when orphan repair merges an empty transcript", async () => { + let seenPrompt: string | undefined; + let seenMessages: unknown[] | undefined; + hoisted.sessionManager.getLeafEntry.mockReturnValueOnce({ + id: "orphan-leaf", + parentId: "parent-leaf", + type: "message", + message: { role: "user", content: "orphaned ask", timestamp: 1 }, + }); + + const result = await createContextEngineAttemptRunner({ + contextEngine: createContextEngineBootstrapAndAssemble(), + sessionKey, + tempPaths, + trajectory: true, + attemptOverrides: { + prompt: "internal heartbeat event", + transcriptPrompt: "", + }, + sessionPrompt: async (session, prompt) => { + seenPrompt = prompt; + seenMessages = [...session.messages]; + session.messages = [ + ...session.messages, + { role: "assistant", content: "done", timestamp: 2 }, + ]; + }, + }); + + expect(seenPrompt).toContain("orphaned ask"); + expect(seenPrompt).not.toContain("internal heartbeat event"); + expect(result.finalPromptText).toBe(seenPrompt); + const trajectoryEvents = ( + await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as TrajectoryEvent); + const contextCompiled = trajectoryEvents.find((event) => event.type === "context.compiled"); + const runtimeContext = findRecord( + requireRecords(seenMessages, "seen messages"), + (message) => message.customType === "openclaw.runtime-context", + "runtime context message", + ); + expect(runtimeContext.content).toContain("internal heartbeat event"); + expect(contextCompiled?.data?.systemPrompt).not.toContain("internal heartbeat event"); + expect(JSON.stringify(result.messagesSnapshot)).not.toContain("internal heartbeat event"); + expect(hoisted.sessionManager.branch).toHaveBeenCalledWith("parent-leaf"); }); it("keeps current inbound context visible on runtime-only turns", async () => { @@ -1172,6 +1396,16 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { it("submits suppressed room event context as the model prompt", async () => { let seenPrompt: string | undefined; + let seenModelMessages: unknown[] | undefined; + const runBeforePromptBuild = vi.fn(async () => ({ + prependContext: "dynamic hook context", + appendContext: "dynamic hook tail", + })); + hoisted.getGlobalHookRunnerMock.mockReturnValue({ + hasHooks: vi.fn((name: string) => name === "before_prompt_build"), + runBeforePromptBuild, + runBeforeAgentStart: vi.fn(), + }); const result = await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), @@ -1196,6 +1430,14 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }, sessionPrompt: async (session, prompt) => { seenPrompt = prompt; + const transformContext = ( + session.agent as { + transformContext?: (messages: AgentMessage[]) => Promise; + } + ).transformContext; + seenModelMessages = await transformContext?.([ + { role: "user", content: [{ type: "text", text: prompt }], timestamp: 1 }, + ]); session.messages = [ ...session.messages, { role: "assistant", content: "done", timestamp: 2 }, @@ -1209,6 +1451,10 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(seenPrompt).toContain("Current event:\n#2003 Bob: hey claw summarize the plan"); expect(seenPrompt?.trim().endsWith("[OpenClaw room event]")).toBe(true); expect(seenPrompt).not.toBe("Continue the OpenClaw runtime event."); + expect(seenPrompt).not.toContain("dynamic hook context"); + expect(seenPrompt).not.toContain("dynamic hook tail"); + expect(JSON.stringify(seenModelMessages)).toContain("dynamic hook context"); + expect(JSON.stringify(seenModelMessages)).toContain("dynamic hook tail"); expect(result.finalPromptText).toBe(seenPrompt); const trajectoryEvents = ( await fs.readFile(path.join(tempPaths[0] ?? "", "session.trajectory.jsonl"), "utf8") diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index 38746a7f644..f31bc263834 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -848,7 +848,10 @@ export type MutableSession = { systemPrompt?: string; }; }; - prompt: (prompt: string, options?: { images?: unknown[] }) => Promise; + prompt: ( + prompt: string, + options?: { images?: unknown[]; preflightResult?: (submitted: boolean) => void }, + ) => Promise; sendCustomMessage: ( message: { customType: string; @@ -867,7 +870,7 @@ export type MutableSession = { type SessionPromptOverride = ( session: MutableSession, prompt: string, - options?: { images?: unknown[] }, + options?: { images?: unknown[]; preflightResult?: (submitted: boolean) => void }, ) => Promise; type TestAgentStream = { @@ -1009,13 +1012,13 @@ export function createDefaultEmbeddedSession(params?: { prompt?: ( session: MutableSession, prompt: string, - options?: { images?: unknown[] }, + options?: { images?: unknown[]; preflightResult?: (submitted: boolean) => void }, ) => Promise; }): MutableSession { let pendingPrompt: | { prompt: string; - options?: { images?: unknown[] }; + options?: { images?: unknown[]; preflightResult?: (submitted: boolean) => void }; } | undefined; const session: MutableSession = { @@ -1025,13 +1028,20 @@ export function createDefaultEmbeddedSession(params?: { isStreaming: false, agent: { prompt: async (prompt, options) => { - pendingPrompt = { prompt: String(prompt), options: options as { images?: unknown[] } }; + pendingPrompt = { + prompt: String(prompt), + options: options as { + images?: unknown[]; + preflightResult?: (submitted: boolean) => void; + }, + }; await session.agent.streamFn?.(); }, streamFn: async () => { if (params?.prompt && pendingPrompt) { const currentPrompt = pendingPrompt; pendingPrompt = undefined; + currentPrompt.options?.preflightResult?.(true); await params.prompt(session, currentPrompt.prompt, currentPrompt.options); } return createCompletedAssistantStream(); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index fb5f181d6af..eb48ab78328 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -280,6 +280,7 @@ import { import { installContextEngineLoopHook, installToolResultContextGuard, + markTranscriptPromptText, } from "../tool-result-context-guard.js"; import { resolveLiveToolResultMaxChars, @@ -1025,6 +1026,136 @@ function installRuntimeContextMessageForPrompt(params: { }; } +function replaceLastUserTextPrompt(params: { + messages: AgentMessage[]; + shouldCapture?: (message: AgentMessage) => boolean; + transcriptText?: string; + replace: (text: string) => string | undefined; +}): AgentMessage[] { + const userIndex = params.messages.findLastIndex((message) => message.role === "user"); + if (userIndex === -1) { + return params.messages; + } + const message = params.messages[userIndex]; + if (!message || message.role !== "user") { + return params.messages; + } + if (params.shouldCapture && !params.shouldCapture(message)) { + return params.messages; + } + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + const replacement = params.replace(content); + if (replacement === undefined) { + return params.messages; + } + const next = params.messages.slice(); + next[userIndex] = { ...message, content: replacement } as AgentMessage; + if (params.transcriptText !== undefined) { + markTranscriptPromptText(next[userIndex], params.transcriptText); + } + return next; + } + if (!Array.isArray(content)) { + return params.messages; + } + let replaced = false; + const nextContent = content.map((block) => { + if (replaced || !block || typeof block !== "object") { + return block; + } + const textBlock = block as { type?: unknown; text?: unknown }; + if (textBlock.type !== "text" || typeof textBlock.text !== "string") { + return block; + } + const replacement = params.replace(textBlock.text); + if (replacement === undefined) { + return block; + } + replaced = true; + return Object.assign({}, block, { text: replacement }); + }); + if (!replaced) { + return params.messages; + } + const next = params.messages.slice(); + next[userIndex] = { ...message, content: nextContent } as AgentMessage; + if (params.transcriptText !== undefined) { + markTranscriptPromptText(next[userIndex], params.transcriptText); + } + return next; +} + +function composeModelPromptContext(params: { + prompt: string; + prependContext?: string; + appendContext?: string; +}): string { + return [params.prependContext, params.prompt, params.appendContext] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n\n"); +} + +function installModelPromptTransform(params: { + session: AgentSession; + transcriptPrompt: string; + modelPrompt?: string; + prependContext?: string; + appendContext?: string; + shouldCapturePrompt: () => boolean; +}): () => void { + const modelPrompt = params.modelPrompt; + const hasPromptContext = + Boolean(params.prependContext?.trim()) || Boolean(params.appendContext?.trim()); + if ((!modelPrompt?.trim() || modelPrompt === params.transcriptPrompt) && !hasPromptContext) { + return () => undefined; + } + const agent = params.session.agent as { + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + }; + const originalTransformContext = agent.transformContext; + let targetPromptTimestamp: number | undefined; + agent.transformContext = async (messages, signal) => { + const promptMessages = replaceLastUserTextPrompt({ + messages, + transcriptText: params.transcriptPrompt, + shouldCapture: (message) => { + const timestamp = (message as { timestamp?: unknown }).timestamp; + if (targetPromptTimestamp !== undefined) { + return timestamp === targetPromptTimestamp; + } + if (!params.shouldCapturePrompt()) { + return false; + } + if (typeof timestamp === "number") { + targetPromptTimestamp = timestamp; + } + return true; + }, + replace: (text) => { + if (modelPrompt?.trim() && text === params.transcriptPrompt) { + return modelPrompt; + } + if (!hasPromptContext) { + return undefined; + } + const replacement = composeModelPromptContext({ + prompt: text, + prependContext: params.prependContext, + appendContext: params.appendContext, + }); + return replacement === text ? undefined : replacement; + }, + }); + return originalTransformContext + ? await originalTransformContext.call(agent, promptMessages, signal) + : promptMessages; + }; + return () => { + agent.transformContext = originalTransformContext; + }; +} + function appendRuntimeContextMessageForPrompt(params: { message: RuntimeContextCustomMessage; messages: AgentMessage[]; @@ -3868,6 +3999,11 @@ export async function runEmbeddedAttempt( hookRunner, beforeAgentStartResult: params.beforeAgentStartResult, }); + const promptBeforePromptBuildHooks = effectivePrompt; + const promptBuildPrependContext = hookResult?.prependContext; + const promptBuildAppendContext = hookResult?.appendContext; + const hasPromptBuildContext = + Boolean(promptBuildPrependContext?.trim()) || Boolean(promptBuildAppendContext?.trim()); { if (hookResult?.prependContext) { effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`; @@ -3953,15 +4089,37 @@ export async function runEmbeddedAttempt( `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId} ` + routingSummary, ); + const effectiveTranscriptPrompt = + params.transcriptPrompt === undefined ? undefined : params.transcriptPrompt; + let transcriptPromptForRuntimeSplit = effectiveTranscriptPrompt; + let promptForRuntimeContextSplit = promptBeforePromptBuildHooks; // Repair orphaned trailing user messages so new prompts don't violate role ordering. const leafEntry = isRawModelRun ? null : sessionManager.getLeafEntry(); if (leafEntry?.type === "message" && leafEntry.message.role === "user") { - const orphanPromptMerge = resolveMessageMergeStrategy().mergeOrphanedTrailingUserPrompt({ + const messageMergeStrategy = resolveMessageMergeStrategy(); + const orphanPromptMerge = messageMergeStrategy.mergeOrphanedTrailingUserPrompt({ prompt: effectivePrompt, trigger: params.trigger, leafMessage: leafEntry.message, }); + const runtimePromptMerge = messageMergeStrategy.mergeOrphanedTrailingUserPrompt({ + prompt: promptForRuntimeContextSplit, + trigger: params.trigger, + leafMessage: leafEntry.message, + }); + const transcriptPromptMerge = + effectiveTranscriptPrompt === undefined + ? undefined + : messageMergeStrategy.mergeOrphanedTrailingUserPrompt({ + prompt: effectiveTranscriptPrompt, + trigger: params.trigger, + leafMessage: leafEntry.message, + }); effectivePrompt = orphanPromptMerge.prompt; + promptForRuntimeContextSplit = runtimePromptMerge.prompt; + if (transcriptPromptMerge) { + transcriptPromptForRuntimeSplit = transcriptPromptMerge.prompt; + } if (orphanPromptMerge.removeLeaf) { if (leafEntry.parentId) { sessionManager.branch(leafEntry.parentId); @@ -3989,11 +4147,13 @@ export async function runEmbeddedAttempt( log.debug(orphanRepairMessage); } } + const promptForModelBeforeRuntimeContextSplit = effectivePrompt; if (!isRawModelRun) { - effectivePrompt = annotateInterSessionPromptText(effectivePrompt, params.inputProvenance); + promptForRuntimeContextSplit = annotateInterSessionPromptText( + promptForRuntimeContextSplit, + params.inputProvenance, + ); } - const effectiveTranscriptPrompt = - params.transcriptPrompt === undefined ? undefined : params.transcriptPrompt; const transcriptLeafId = (sessionManager.getLeafEntry() as { id?: string } | null | undefined)?.id ?? null; const heartbeatSummary = @@ -4013,16 +4173,23 @@ export async function runEmbeddedAttempt( prePromptMessageCount = activeSession.messages.length; const promptSubmission = resolveRuntimeContextPromptParts({ - effectivePrompt, - transcriptPrompt: effectiveTranscriptPrompt, + effectivePrompt: promptForRuntimeContextSplit, + transcriptPrompt: transcriptPromptForRuntimeSplit, + modelPrompt: hasPromptBuildContext + ? promptForModelBeforeRuntimeContextSplit + : undefined, emptyTranscriptMode: params.suppressNextUserMessagePersistence ? "model-prompt" : "runtime-event", }); - const promptForModel = buildCurrentInboundPrompt({ + const promptForSession = buildCurrentInboundPrompt({ context: params.currentInboundContext, prompt: promptSubmission.prompt, }); + const promptForModel = buildCurrentInboundPrompt({ + context: params.currentInboundContext, + prompt: promptSubmission.modelPrompt ?? promptSubmission.prompt, + }); const runtimeSystemContext = promptSubmission.runtimeSystemContext?.trim(); if (promptSubmission.runtimeOnly && runtimeSystemContext) { const runtimeSystemPrompt = composeSystemPromptWithHookContext({ @@ -4471,7 +4638,7 @@ export async function runEmbeddedAttempt( if (normalizedReplayMessages !== activeSession.messages) { activeSession.agent.state.messages = normalizedReplayMessages; } - finalPromptText = promptForModel; + finalPromptText = promptForSession; trajectoryRecorder?.recordEvent("prompt.submitted", { prompt: promptForModel, systemPrompt: systemPromptForHook, @@ -4482,26 +4649,51 @@ export async function runEmbeddedAttempt( updateActiveEmbeddedRunSnapshot(params.sessionId, { transcriptLeafId, messages: btwSnapshotMessages, - inFlightPrompt: promptForModel, + inFlightPrompt: promptForSession, }); - if (promptSubmission.runtimeOnly) { - await promptActiveSession(promptForModel); - } else { - const cleanupRuntimeContextMessage = installRuntimeContextMessageForPrompt({ - session: activeSession, - message: runtimeContextMessageForCurrentTurn, - }); - try { - // Only pass images option if there are actually images to pass - // This avoids potential issues with models that don't expect the images parameter - if (imageResult.images.length > 0) { - await promptActiveSession(promptForModel, { images: imageResult.images }); - } else { - await promptActiveSession(promptForModel); - } - } finally { - cleanupRuntimeContextMessage(); + let captureCurrentPromptForModel = false; + const cleanupModelPromptTransform = installModelPromptTransform({ + session: activeSession, + transcriptPrompt: promptForSession, + modelPrompt: promptForModel, + prependContext: promptBuildPrependContext, + appendContext: promptBuildAppendContext, + shouldCapturePrompt: () => captureCurrentPromptForModel, + }); + const armModelPromptTransform = (submitted: boolean) => { + if (submitted) { + captureCurrentPromptForModel = true; } + }; + try { + if (promptSubmission.runtimeOnly) { + await promptActiveSession(promptForSession, { + preflightResult: armModelPromptTransform, + }); + } else { + const cleanupRuntimeContextMessage = installRuntimeContextMessageForPrompt({ + session: activeSession, + message: runtimeContextMessageForCurrentTurn, + }); + try { + // Only pass images option if there are actually images to pass + // This avoids potential issues with models that don't expect the images parameter + if (imageResult.images.length > 0) { + await promptActiveSession(promptForSession, { + images: imageResult.images, + preflightResult: armModelPromptTransform, + }); + } else { + await promptActiveSession(promptForSession, { + preflightResult: armModelPromptTransform, + }); + } + } finally { + cleanupRuntimeContextMessage(); + } + } + } finally { + cleanupModelPromptTransform(); } } } catch (err) { diff --git a/src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts b/src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts index a8804e6fb9e..c40351f03db 100644 --- a/src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts +++ b/src/agents/embedded-agent-runner/run/runtime-context-prompt.test.ts @@ -38,15 +38,162 @@ describe("runtime context prompt submission", () => { }); }); - it("preserves prompt additions as hidden runtime context", () => { + it("keeps prompt-local additions in the model prompt", () => { expect( resolveRuntimeContextPromptParts({ effectivePrompt: ["runtime prefix", "", "visible ask", "", "retry instruction"].join("\n"), transcriptPrompt: "visible ask", + modelPrompt: ["runtime prefix", "", "visible ask", "", "retry instruction"].join("\n"), + }), + ).toEqual({ + prompt: "visible ask", + modelPrompt: "runtime prefix\n\nvisible ask\n\nretry instruction", + }); + }); + + it("preserves unsplit prompt whitespace", () => { + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt: " keep literal whitespace ", + }), + ).toEqual({ + prompt: " keep literal whitespace ", + }); + }); + + it("keeps no-transcript prompt-local additions in the model prompt", () => { + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt: "visible ask", + modelPrompt: ["runtime prefix", "", "visible ask", "", "retry instruction"].join("\n"), + }), + ).toEqual({ + prompt: "visible ask", + modelPrompt: "runtime prefix\n\nvisible ask\n\nretry instruction", + }); + }); + + it("keeps hidden runtime context separate from prompt-local additions", () => { + const prompt = ["runtime prefix", "", "visible ask", "", "retry instruction"].join("\n"); + const effectivePrompt = [ + prompt, + "", + "<<>>", + "secret runtime context", + "<<>>", + ].join("\n"); + + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt, + transcriptPrompt: "visible ask", + modelPrompt: effectivePrompt, + }), + ).toEqual({ + prompt: "visible ask", + modelPrompt: prompt, + runtimeContext: + "<<>>\nsecret runtime context\n<<>>", + }); + }); + + it("does not extract no-transcript delimiter text", () => { + const effectivePrompt = [ + "visible ask", + "", + "<<>>", + "secret runtime context", + "<<>>", + ].join("\n"); + + expect(resolveRuntimeContextPromptParts({ effectivePrompt })).toEqual({ + prompt: effectivePrompt, + }); + }); + + it("extracts multiple hidden runtime context blocks", () => { + const effectivePrompt = [ + "runtime prefix", + "", + "<<>>", + "first secret", + "<<>>", + "", + "visible ask", + "", + "<<>>", + "second secret", + "<<>>", + "", + "retry instruction", + ].join("\n"); + + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt, + transcriptPrompt: "visible ask", + modelPrompt: effectivePrompt, + }), + ).toEqual({ + prompt: "visible ask", + modelPrompt: "runtime prefix\n\nvisible ask\n\nretry instruction", + runtimeContext: [ + "<<>>\nfirst secret\n<<>>", + "", + "<<>>\nsecond secret\n<<>>", + ].join("\n"), + }); + }); + + it("ignores repeated inline marker mentions without recursive stack growth", () => { + const inlineMarkers = Array.from( + { length: 250 }, + () => "inline <<>> marker", + ).join("\n"); + const effectivePrompt = [ + inlineMarkers, + "", + "visible ask", + "", + "<<>>", + "secret runtime context", + "<<>>", + ].join("\n"); + + const parts = resolveRuntimeContextPromptParts({ + effectivePrompt, + transcriptPrompt: "visible ask", + modelPrompt: effectivePrompt, + }); + + expect(parts.prompt).toContain("visible ask"); + expect(parts.modelPrompt).toContain("inline <<>> marker"); + expect(parts.modelPrompt).toContain("visible ask"); + expect(parts.modelPrompt).not.toContain("secret runtime context"); + expect(parts.prompt).not.toContain("secret runtime context"); + expect(parts.runtimeContext).toBe( + "<<>>\nsecret runtime context\n<<>>", + ); + }); + + it("fails closed for unterminated hidden runtime context blocks", () => { + const effectivePrompt = [ + "visible ask", + "", + "<<>>", + "secret runtime context", + "", + "still secret", + ].join("\n"); + + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt, + transcriptPrompt: "visible ask", + modelPrompt: effectivePrompt, }), ).toEqual({ prompt: "visible ask", - runtimeContext: "runtime prefix\n\nretry instruction", }); }); @@ -69,6 +216,29 @@ describe("runtime context prompt submission", () => { }); }); + it("keeps runtime-only hook context in the model prompt", () => { + const parts = resolveRuntimeContextPromptParts({ + effectivePrompt: "internal event", + transcriptPrompt: "", + modelPrompt: ["dynamic hook context", "", "internal event", "", "dynamic hook tail"].join( + "\n", + ), + }); + + expect(parts).toEqual({ + prompt: "Continue the OpenClaw runtime event.", + modelPrompt: "dynamic hook context\n\ninternal event\n\ndynamic hook tail", + runtimeContext: "internal event", + runtimeOnly: true, + runtimeSystemContext: [ + "OpenClaw runtime event.", + "This context is runtime-generated, not user-authored. Keep internal details private.", + "", + "internal event", + ].join("\n"), + }); + }); + it("submits empty-transcript model prompts when persistence is suppressed separately", () => { expect( resolveRuntimeContextPromptParts({ @@ -81,6 +251,26 @@ describe("runtime context prompt submission", () => { }); }); + it("keeps suppressed empty-transcript hook context model-only", () => { + expect( + resolveRuntimeContextPromptParts({ + effectivePrompt: "[OpenClaw room event]", + transcriptPrompt: "", + modelPrompt: [ + "dynamic hook context", + "", + "[OpenClaw room event]", + "", + "dynamic hook tail", + ].join("\n"), + emptyTranscriptMode: "model-prompt", + }), + ).toEqual({ + prompt: "[OpenClaw room event]", + modelPrompt: "dynamic hook context\n\n[OpenClaw room event]\n\ndynamic hook tail", + }); + }); + it("uses current-turn context as prompt-local text", () => { expect( buildCurrentInboundPromptContextPrefix({ diff --git a/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts b/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts index 0974b62977a..bf83342e7a1 100644 --- a/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts +++ b/src/agents/embedded-agent-runner/run/runtime-context-prompt.ts @@ -1,4 +1,5 @@ import { + extractInternalRuntimeContext, OPENCLAW_NEXT_TURN_RUNTIME_CONTEXT_HEADER, OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE, OPENCLAW_RUNTIME_CONTEXT_NOTICE, @@ -11,6 +12,7 @@ const OPENCLAW_RUNTIME_EVENT_USER_PROMPT = "Continue the OpenClaw runtime event. type RuntimeContextPromptParts = { prompt: string; + modelPrompt?: string; runtimeContext?: string; runtimeOnly?: boolean; runtimeSystemContext?: string; @@ -63,32 +65,67 @@ function removeLastPromptOccurrence(text: string, prompt: string): string | null export function resolveRuntimeContextPromptParts(params: { effectivePrompt: string; transcriptPrompt?: string; + modelPrompt?: string; emptyTranscriptMode?: EmptyTranscriptMode; }): RuntimeContextPromptParts { const transcriptPrompt = params.transcriptPrompt; - if (transcriptPrompt === undefined || transcriptPrompt === params.effectivePrompt) { - return { prompt: params.effectivePrompt }; - } - - const prompt = transcriptPrompt.trim(); - if (!prompt && params.emptyTranscriptMode === "model-prompt") { - return { prompt: params.effectivePrompt }; + const shouldExtractInternalRuntimeContext = transcriptPrompt !== undefined; + const extracted = shouldExtractInternalRuntimeContext + ? extractInternalRuntimeContext(params.effectivePrompt) + : { text: params.effectivePrompt }; + const modelPrompt = + params.modelPrompt === undefined + ? undefined + : shouldExtractInternalRuntimeContext + ? extractInternalRuntimeContext(params.modelPrompt) + : { text: params.modelPrompt }; + const modelPromptText = modelPrompt?.text ?? transcriptPrompt ?? extracted.text; + const prompt = transcriptPrompt ?? extracted.text; + if (!prompt.trim() && params.emptyTranscriptMode === "model-prompt") { + return { + prompt: extracted.text, + ...(modelPromptText.trim() && modelPromptText !== extracted.text + ? { modelPrompt: modelPromptText } + : {}), + ...(extracted.runtimeContext ? { runtimeContext: extracted.runtimeContext } : {}), + }; } + const hiddenRuntimeContext = modelPrompt + ? (removeLastPromptOccurrence(extracted.text, modelPrompt.text)?.trim() ?? + (transcriptPrompt + ? removeLastPromptOccurrence(extracted.text, transcriptPrompt)?.trim() + : undefined)) + : transcriptPrompt + ? removeLastPromptOccurrence(extracted.text, transcriptPrompt)?.trim() + : undefined; const runtimeContext = - removeLastPromptOccurrence(params.effectivePrompt, transcriptPrompt)?.trim() || - params.effectivePrompt.trim(); - if (!prompt) { + [hiddenRuntimeContext, extracted.runtimeContext] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n\n") || (!prompt.trim() ? extracted.text.trim() : undefined); + if (!prompt.trim()) { return runtimeContext ? { prompt: OPENCLAW_RUNTIME_EVENT_USER_PROMPT, + ...(modelPromptText.trim() && modelPromptText !== OPENCLAW_RUNTIME_EVENT_USER_PROMPT + ? { modelPrompt: modelPromptText } + : {}), runtimeContext, runtimeOnly: true, runtimeSystemContext: buildRuntimeEventSystemContext(runtimeContext), } - : { prompt: "" }; + : { + prompt: "", + ...(modelPromptText ? { modelPrompt: modelPromptText } : {}), + }; } - return runtimeContext ? { prompt, runtimeContext } : { prompt }; + return { + prompt, + ...(modelPromptText.trim() && modelPromptText !== prompt + ? { modelPrompt: modelPromptText } + : {}), + ...(runtimeContext ? { runtimeContext } : {}), + }; } function buildRuntimeContextMessageContent(params: { diff --git a/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts b/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts index f8eeed0399e..2f7a0c9ebe1 100644 --- a/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-context-guard.test.ts @@ -8,6 +8,7 @@ import { formatContextLimitTruncationNotice, installContextEngineLoopHook, installToolResultContextGuard, + markTranscriptPromptText, PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE, } from "./tool-result-context-guard.js"; @@ -669,6 +670,34 @@ describe("installContextEngineLoopHook", () => { }); }); + it("projects marked model prompts for ingest without leaking the marker to assembly", async () => { + const agent = makeGuardableAgent(); + const engine = makeMockEngine(); + installHook(agent, engine, 0); + + const modelPrompt = makeUser("model-only hook context\n\nvisible prompt"); + markTranscriptPromptText(modelPrompt, "visible prompt"); + const messages = [modelPrompt, makeToolResult("call_1", "result")]; + const transformed = await callTransform(agent, messages); + + const afterTurnMessage = (recordMockArg(engine.afterTurn).messages as AgentMessage[])[0]; + const assembleMessage = (recordMockArg(engine.assemble).messages as AgentMessage[])[0]; + const transformedMessage = (transformed as AgentMessage[])[0]; + + expect(afterTurnMessage).toMatchObject({ role: "user", content: "visible prompt" }); + expect(JSON.stringify(afterTurnMessage)).not.toContain("__openclawTranscriptPromptText"); + expect(assembleMessage).toMatchObject({ + role: "user", + content: "model-only hook context\n\nvisible prompt", + }); + expect(JSON.stringify(assembleMessage)).not.toContain("__openclawTranscriptPromptText"); + expect(transformedMessage).toMatchObject({ + role: "user", + content: "model-only hook context\n\nvisible prompt", + }); + expect(JSON.stringify(transformedMessage)).not.toContain("__openclawTranscriptPromptText"); + }); + it("calls afterTurn and assemble when new messages are appended after the first call", async () => { const agent = makeGuardableAgent(); const engine = makeMockEngine(); diff --git a/src/agents/embedded-agent-runner/tool-result-context-guard.ts b/src/agents/embedded-agent-runner/tool-result-context-guard.ts index e78f9946423..db178c0152b 100644 --- a/src/agents/embedded-agent-runner/tool-result-context-guard.ts +++ b/src/agents/embedded-agent-runner/tool-result-context-guard.ts @@ -25,6 +25,7 @@ const PREEMPTIVE_OVERFLOW_RATIO = 0.9; export const PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE = "Context overflow: estimated context size exceeds safe threshold during tool loop."; const TOOL_RESULT_ESTIMATE_TO_TEXT_RATIO = 4 / TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE; +const TRANSCRIPT_PROMPT_TEXT_KEY = "__openclawTranscriptPromptText"; type GuardableTransformContext = ( messages: AgentMessage[], @@ -49,6 +50,90 @@ type MidTurnPrecheckOptions = { export { CONTEXT_LIMIT_TRUNCATION_NOTICE, formatContextLimitTruncationNotice }; +export function markTranscriptPromptText(message: AgentMessage, text: string): void { + Object.defineProperty(message, TRANSCRIPT_PROMPT_TEXT_KEY, { + configurable: true, + enumerable: true, + value: text, + }); +} + +function getTranscriptPromptText(message: AgentMessage): string | undefined { + const value = (message as unknown as Record)[TRANSCRIPT_PROMPT_TEXT_KEY]; + return typeof value === "string" ? value : undefined; +} + +function restoreTranscriptPromptText( + message: AgentMessage, + cache: WeakMap, +): AgentMessage { + const transcriptText = getTranscriptPromptText(message); + if (transcriptText === undefined || message.role !== "user") { + return message; + } + const cached = cache.get(message); + if (cached) { + return cached; + } + const content = (message as { content?: unknown }).content; + const { [TRANSCRIPT_PROMPT_TEXT_KEY]: _transcriptPromptText, ...messageRest } = + message as unknown as Record; + let restoredMessage: AgentMessage = message; + if (typeof content === "string") { + restoredMessage = { ...messageRest, content: transcriptText } as unknown as AgentMessage; + } else if (Array.isArray(content)) { + let restored = false; + const nextContent = content.map((block) => { + if (restored || !block || typeof block !== "object") { + return block; + } + const textBlock = block as { type?: unknown; text?: unknown }; + if (textBlock.type !== "text" || typeof textBlock.text !== "string") { + return block; + } + restored = true; + return Object.assign({}, block, { text: transcriptText }); + }); + if (restored) { + restoredMessage = { ...messageRest, content: nextContent } as unknown as AgentMessage; + } + } + cache.set(message, restoredMessage); + return restoredMessage; +} + +function stripTranscriptPromptMarker(message: AgentMessage): AgentMessage { + if (getTranscriptPromptText(message) === undefined) { + return message; + } + const { [TRANSCRIPT_PROMPT_TEXT_KEY]: _transcriptPromptText, ...messageRest } = + message as unknown as Record; + return messageRest as unknown as AgentMessage; +} + +function projectTranscriptPromptMessages( + messages: AgentMessage[], + cache: WeakMap, +): AgentMessage[] { + let changed = false; + const projected = messages.map((message) => { + const next = restoreTranscriptPromptText(message, cache); + changed ||= next !== message; + return next; + }); + return changed ? projected : messages; +} + +function stripTranscriptPromptMarkers(messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const stripped = messages.map((message) => { + const next = stripTranscriptPromptMarker(message); + changed ||= next !== message; + return next; + }); + return changed ? stripped : messages; +} + function truncateTextToBudget(text: string, maxChars: number): string { if (text.length <= maxChars) { return text; @@ -252,20 +337,26 @@ export function installContextEngineLoopHook(params: { let lastSeenLength: number | null = null; let lastAssembledView: AgentMessage[] | null = null; let lastSourceMessages: AgentMessage[] | null = null; + const transcriptProjectionCache = new WeakMap(); mutableAgent.transformContext = (async (messages: AgentMessage[], signal: AbortSignal) => { const transformed = originalTransformContext ? await originalTransformContext.call(mutableAgent, messages, signal) : messages; const sourceMessages = Array.isArray(transformed) ? transformed : messages; + const transcriptMessages = projectTranscriptPromptMessages( + sourceMessages, + transcriptProjectionCache, + ); + const providerMessages = stripTranscriptPromptMarkers(sourceMessages); const checkedPrefixLength = - lastSeenLength == null ? 0 : Math.min(lastSeenLength, sourceMessages.length); + lastSeenLength == null ? 0 : Math.min(lastSeenLength, transcriptMessages.length); const sourceHistoryChanged = lastSeenLength != null && lastSourceMessages != null && - (sourceMessages.length < lastSeenLength || - (sourceMessages.length === lastSeenLength && - sourceMessages + (transcriptMessages.length < lastSeenLength || + (transcriptMessages.length === lastSeenLength && + transcriptMessages .slice(0, checkedPrefixLength) .some((message, index) => message !== lastSourceMessages?.[index]))); if (sourceHistoryChanged) { @@ -279,16 +370,16 @@ export function installContextEngineLoopHook(params: { const prePromptMessageCount = Math.max( 0, Math.min( - sourceMessages.length, - lastSeenLength ?? params.getPrePromptMessageCount?.() ?? sourceMessages.length, + transcriptMessages.length, + lastSeenLength ?? params.getPrePromptMessageCount?.() ?? transcriptMessages.length, ), ); - const hasNewMessages = sourceMessages.length > prePromptMessageCount; + const hasNewMessages = transcriptMessages.length > prePromptMessageCount; if (!hasNewMessages) { lastSeenLength = prePromptMessageCount; - lastSourceMessages = sourceMessages; - return lastAssembledView ?? sourceMessages; + lastSourceMessages = transcriptMessages; + return lastAssembledView ?? providerMessages; } try { if (typeof contextEngine.afterTurn === "function") { @@ -296,16 +387,16 @@ export function installContextEngineLoopHook(params: { sessionId, sessionKey, sessionFile, - messages: sourceMessages, + messages: transcriptMessages, prePromptMessageCount, tokenBudget, runtimeContext: params.getRuntimeContext?.({ - messages: sourceMessages, + messages: transcriptMessages, prePromptMessageCount, }), }); } else { - const newMessages = sourceMessages.slice(prePromptMessageCount); + const newMessages = transcriptMessages.slice(prePromptMessageCount); if (newMessages.length > 0) { if (typeof contextEngine.ingestBatch === "function") { await contextEngine.ingestBatch({ @@ -324,17 +415,21 @@ export function installContextEngineLoopHook(params: { } } } - lastSeenLength = sourceMessages.length; + lastSeenLength = transcriptMessages.length; params.onAfterTurnCheckpoint?.(lastSeenLength); - lastSourceMessages = sourceMessages; + lastSourceMessages = transcriptMessages; const assembled = await contextEngine.assemble({ sessionId, sessionKey, - messages: sourceMessages, + messages: providerMessages, tokenBudget, model: modelId, }); - if (assembled && Array.isArray(assembled.messages) && assembled.messages !== sourceMessages) { + if ( + assembled && + Array.isArray(assembled.messages) && + assembled.messages !== providerMessages + ) { lastAssembledView = assembled.messages; return assembled.messages; } @@ -344,10 +439,10 @@ export function installContextEngineLoopHook(params: { // messages so the tool loop still makes forward progress. lastSeenLength = prePromptMessageCount; lastAssembledView = null; - lastSourceMessages = sourceMessages; + lastSourceMessages = transcriptMessages; } - return sourceMessages; + return providerMessages; }) as GuardableTransformContext; return () => { diff --git a/src/agents/internal-runtime-context.test.ts b/src/agents/internal-runtime-context.test.ts index 1f0f54710bf..1725594ba86 100644 --- a/src/agents/internal-runtime-context.test.ts +++ b/src/agents/internal-runtime-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { escapeInternalRuntimeContextDelimiters, + extractInternalRuntimeContext, hasInternalRuntimeContext, INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, @@ -34,6 +35,40 @@ describe("internal runtime context codec", () => { expect(stripInternalRuntimeContext(input)).toBe("Visible intro\n\nVisible outro"); }); + it("extracts marked internal runtime blocks and preserves surrounding text", () => { + const first = [ + INTERNAL_RUNTIME_CONTEXT_BEGIN, + "first secret", + INTERNAL_RUNTIME_CONTEXT_END, + ].join("\n"); + const second = [ + INTERNAL_RUNTIME_CONTEXT_BEGIN, + "second secret", + INTERNAL_RUNTIME_CONTEXT_END, + ].join("\n"); + const input = ["Visible intro", "", first, "", "Visible middle", "", second].join("\n"); + + expect(extractInternalRuntimeContext(input)).toEqual({ + text: "Visible intro\n\nVisible middle", + runtimeContext: [first, "", second].join("\n"), + }); + }); + + it("fails closed when extracting malformed marked internal runtime blocks", () => { + const input = [ + "Visible intro", + "", + INTERNAL_RUNTIME_CONTEXT_BEGIN, + "secret runtime context", + "", + "Visible-looking tail", + ].join("\n"); + + expect(extractInternalRuntimeContext(input)).toEqual({ + text: "Visible intro", + }); + }); + it("detects canonical runtime context and ignores inline marker mentions", () => { expect( hasInternalRuntimeContext( diff --git a/src/agents/internal-runtime-context.ts b/src/agents/internal-runtime-context.ts index 81240c05375..adeefd7342d 100644 --- a/src/agents/internal-runtime-context.ts +++ b/src/agents/internal-runtime-context.ts @@ -40,12 +40,17 @@ function findDelimitedTokenIndex(text: string, token: string, from: number): num return match.index + prefixLength; } -function stripDelimitedBlock(text: string, begin: string, end: string): string { +function extractDelimitedBlocks( + text: string, + begin: string, + end: string, +): { text: string; blocks: string[] } { let next = text; + const blocks: string[] = []; for (;;) { const start = findDelimitedTokenIndex(next, begin, 0); if (start === -1) { - return next; + return { text: next, blocks }; } let cursor = start + begin.length; @@ -69,13 +74,19 @@ function stripDelimitedBlock(text: string, begin: string, end: string): string { const before = next.slice(0, start).trimEnd(); if (finish === -1 || depth !== 0) { - return before; + return { text: before, blocks }; } - const after = next.slice(finish + end.length).trimStart(); + const blockEnd = finish + end.length; + blocks.push(next.slice(start, blockEnd).trim()); + const after = next.slice(blockEnd).trimStart(); next = before && after ? `${before}\n\n${after}` : `${before}${after}`; } } +function stripDelimitedBlock(text: string, begin: string, end: string): string { + return extractDelimitedBlocks(text, begin, end).text; +} + function findLegacyInternalEventEnd(text: string, start: number): number | null { if (!text.startsWith(LEGACY_INTERNAL_EVENT_MARKER, start)) { return null; @@ -207,6 +218,21 @@ export function stripInternalRuntimeContext(text: string): string { ); } +export function extractInternalRuntimeContext(text: string): { + text: string; + runtimeContext?: string; +} { + const extracted = extractDelimitedBlocks( + text, + INTERNAL_RUNTIME_CONTEXT_BEGIN, + INTERNAL_RUNTIME_CONTEXT_END, + ); + return { + text: extracted.text, + ...(extracted.blocks.length > 0 ? { runtimeContext: extracted.blocks.join("\n\n") } : {}), + }; +} + export function hasInternalRuntimeContext(text: string): boolean { if (!text) { return false;