mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 03:38:43 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user