fix(agents): keep hook context prompt-local (#86875)

Fixes embedded agent prompt handling so before_prompt_build prepend/append context stays prompt-local: visible transcripts keep the user prompt, provider/model prompts keep hook context, and runtime/system context stays separate.

Local verification:
- git diff --check
- fnm exec --using v22.22.2 pnpm exec oxfmt --check src/agents/embedded-agent-runner/tool-result-context-guard.ts src/agents/embedded-agent-runner/tool-result-context-guard.test.ts
- fnm exec --using v22.22.2 node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json src/agents/embedded-agent-runner/tool-result-context-guard.ts src/agents/embedded-agent-runner/tool-result-context-guard.test.ts
- fnm exec --using v22.22.2 pnpm tsgo:test:src
- autoreview clean: no accepted/actionable findings

CI verification:
- GitHub CI run 26544578760 passed on rebased head 9715d3a01a

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
This commit is contained in:
Alix-007
2026-05-28 07:29:31 +08:00
committed by GitHub
parent 603aa8a2ed
commit 8b7a4826a1
9 changed files with 953 additions and 93 deletions

View File

@@ -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<AgentMessage[]>;
}
).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<AgentMessage[]>;
}
).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",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",
"<<<END_OPENCLAW_INTERNAL_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<AgentMessage[]>;
}
).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<AgentMessage[]>;
}
).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<AgentMessage[]>;
}
).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")

View File

@@ -848,7 +848,10 @@ export type MutableSession = {
systemPrompt?: string;
};
};
prompt: (prompt: string, options?: { images?: unknown[] }) => Promise<void>;
prompt: (
prompt: string,
options?: { images?: unknown[]; preflightResult?: (submitted: boolean) => void },
) => Promise<void>;
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<void>;
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<void>;
}): 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();

View File

@@ -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<AgentMessage[]>;
};
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) {

View File

@@ -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,
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n");
expect(
resolveRuntimeContextPromptParts({
effectivePrompt,
transcriptPrompt: "visible ask",
modelPrompt: effectivePrompt,
}),
).toEqual({
prompt: "visible ask",
modelPrompt: prompt,
runtimeContext:
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nsecret runtime context\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
});
});
it("does not extract no-transcript delimiter text", () => {
const effectivePrompt = [
"visible ask",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n");
expect(resolveRuntimeContextPromptParts({ effectivePrompt })).toEqual({
prompt: effectivePrompt,
});
});
it("extracts multiple hidden runtime context blocks", () => {
const effectivePrompt = [
"runtime prefix",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"first secret",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"visible ask",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"second secret",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"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: [
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nfirst secret\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nsecond secret\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n"),
});
});
it("ignores repeated inline marker mentions without recursive stack growth", () => {
const inlineMarkers = Array.from(
{ length: 250 },
() => "inline <<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>> marker",
).join("\n");
const effectivePrompt = [
inlineMarkers,
"",
"visible ask",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"secret runtime context",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
].join("\n");
const parts = resolveRuntimeContextPromptParts({
effectivePrompt,
transcriptPrompt: "visible ask",
modelPrompt: effectivePrompt,
});
expect(parts.prompt).toContain("visible ask");
expect(parts.modelPrompt).toContain("inline <<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>> 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(
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nsecret runtime context\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
);
});
it("fails closed for unterminated hidden runtime context blocks", () => {
const effectivePrompt = [
"visible ask",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"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({

View File

@@ -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: {

View File

@@ -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();

View File

@@ -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<string, unknown>)[TRANSCRIPT_PROMPT_TEXT_KEY];
return typeof value === "string" ? value : undefined;
}
function restoreTranscriptPromptText(
message: AgentMessage,
cache: WeakMap<AgentMessage, AgentMessage>,
): 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<string, unknown>;
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<string, unknown>;
return messageRest as unknown as AgentMessage;
}
function projectTranscriptPromptMessages(
messages: AgentMessage[],
cache: WeakMap<AgentMessage, AgentMessage>,
): 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<AgentMessage, AgentMessage>();
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 () => {

View File

@@ -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(

View File

@@ -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;