mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(auto-reply): align prepared delivery prompts
This commit is contained in:
@@ -161,21 +161,30 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
const shouldInjectGroupIntro = Boolean(
|
||||
isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
);
|
||||
const directChatContext = isDirectChat
|
||||
? buildDirectChatContext({
|
||||
const buildSourceConversationContext = (mode: typeof sourceReplyDeliveryMode) => {
|
||||
if (isDirectChat) {
|
||||
return buildDirectChatContext({
|
||||
sourceReplyDeliveryMode: mode,
|
||||
sessionCtx: promptSessionCtx,
|
||||
sourceReplyDeliveryMode: sessionPromptSourceReplyDeliveryMode,
|
||||
})
|
||||
: "";
|
||||
// Always include persistent group chat context (provider + reply guidance).
|
||||
const groupChatContext = isGroupChat
|
||||
? buildGroupChatContext({
|
||||
sessionCtx: promptSessionCtx,
|
||||
sourceReplyDeliveryMode: sessionPromptSourceReplyDeliveryMode,
|
||||
silentReplyPolicy: silentReplySettings.policy,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
})
|
||||
: "";
|
||||
});
|
||||
}
|
||||
return isGroupChat
|
||||
? buildGroupChatContext({
|
||||
sessionCtx: promptSessionCtx,
|
||||
sourceReplyDeliveryMode: mode,
|
||||
silentReplyPolicy: silentReplySettings.policy,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
})
|
||||
: "";
|
||||
};
|
||||
const sourceConversationContextByMode = {
|
||||
automatic: buildSourceConversationContext("automatic"),
|
||||
message_tool_only: buildSourceConversationContext("message_tool_only"),
|
||||
};
|
||||
// CLI sessions keep their creation-time conversation prompt. Embedded attempts
|
||||
// can instead select the variant owned by their final prepared harness.
|
||||
const sessionStableConversationContext =
|
||||
sourceConversationContextByMode[sessionPromptSourceReplyDeliveryMode ?? "automatic"];
|
||||
// Claude CLI fixes the system prompt at session creation; group intro must stay session-stable.
|
||||
const groupIntro = isGroupChat ? buildGroupIntro({ sessionEntry, defaultActivation }) : "";
|
||||
const isDirectedTurn =
|
||||
@@ -199,21 +208,34 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
});
|
||||
const extraSystemPromptParts = [
|
||||
inboundMetaPrompt,
|
||||
directChatContext,
|
||||
groupChatContext,
|
||||
sessionStableConversationContext,
|
||||
groupIntro,
|
||||
groupSystemPrompt,
|
||||
execOverridePromptHint,
|
||||
].filter(Boolean);
|
||||
const extraSystemPromptStatic = [
|
||||
directChatContext,
|
||||
groupChatContext,
|
||||
sessionStableConversationContext,
|
||||
groupIntro,
|
||||
groupSystemPrompt,
|
||||
execOverridePromptHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const buildExtraSystemPrompt = (mode: keyof typeof sourceConversationContextByMode) =>
|
||||
[
|
||||
inboundMetaPrompt,
|
||||
sourceConversationContextByMode[mode],
|
||||
groupIntro,
|
||||
groupSystemPrompt,
|
||||
execOverridePromptHint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
// Prepared harness selects one bounded variant; both are never model-visible together.
|
||||
const extraSystemPromptBySourceReplyDeliveryMode = {
|
||||
automatic: buildExtraSystemPrompt("automatic"),
|
||||
message_tool_only: buildExtraSystemPrompt("message_tool_only"),
|
||||
};
|
||||
const cliSessionBindingFacts = {
|
||||
extraSystemPromptStatic,
|
||||
...(sessionPromptSourceReplyDeliveryMode
|
||||
@@ -221,7 +243,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
: {}),
|
||||
};
|
||||
const silentReplyPromptMode: SilentReplyPromptMode =
|
||||
directChatContext || groupChatContext || sourceReplyDeliveryMode === "message_tool_only"
|
||||
sessionStableConversationContext || sourceReplyDeliveryMode === "message_tool_only"
|
||||
? "none"
|
||||
: "generic";
|
||||
const baseBody = sessionCtx.agentText ?? "";
|
||||
@@ -399,6 +421,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
fullAccessState,
|
||||
isFirstTurnInSession,
|
||||
extraSystemPromptParts,
|
||||
extraSystemPromptBySourceReplyDeliveryMode,
|
||||
extraSystemPromptStatic,
|
||||
cliSessionBindingFacts,
|
||||
baseBodyTrimmedRaw,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { resolveReplyToMode } from "./reply-threading.js";
|
||||
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
|
||||
import {
|
||||
setSourceReplyDeliveryModeOrigin,
|
||||
setSourceReplyDeliveryPromptVariants,
|
||||
type SourceReplyDeliveryRuntimeOptions,
|
||||
} from "./source-reply-delivery-runtime.js";
|
||||
import {
|
||||
@@ -86,6 +87,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
|
||||
useFastReplyRuntime,
|
||||
fullAccessState,
|
||||
extraSystemPromptParts,
|
||||
extraSystemPromptBySourceReplyDeliveryMode,
|
||||
extraSystemPromptStatic,
|
||||
cliSessionBindingFacts,
|
||||
baseBodyTrimmedRaw,
|
||||
@@ -455,6 +457,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
|
||||
followupRun.run,
|
||||
sourceReplyDeliveryRuntimeOptions?.sourceReplyDeliveryModeOrigin,
|
||||
);
|
||||
setSourceReplyDeliveryPromptVariants(followupRun.run, extraSystemPromptBySourceReplyDeliveryMode);
|
||||
const replyThreadingOverride =
|
||||
isBareSessionReset && sessionCtx.ReplyThreading?.implicitCurrentMessage !== "deny"
|
||||
? { ...sessionCtx.ReplyThreading, implicitCurrentMessage: "deny" as const }
|
||||
|
||||
@@ -36,6 +36,7 @@ import { testing as replyRunTesting } from "./reply-run-registry.test-support.js
|
||||
import { routeReply } from "./route-reply.runtime.js";
|
||||
import { drainFormattedSystemEvents } from "./session-system-events.js";
|
||||
import {
|
||||
publishPreparedHarnessSourceReplyDeliveryMode,
|
||||
readSourceReplyDeliveryModeOrigin,
|
||||
type SourceReplyDeliveryRuntimeOptions,
|
||||
} from "./source-reply-delivery-runtime.js";
|
||||
@@ -637,10 +638,11 @@ describe("runPreparedReply media-only handling", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildDirectChatContext).toHaveBeenCalledTimes(1);
|
||||
expect(buildDirectChatContext).toHaveBeenCalledTimes(2);
|
||||
const directContextParams = requireMockCallArg(
|
||||
vi.mocked(buildDirectChatContext),
|
||||
"direct chat context",
|
||||
1,
|
||||
) as {
|
||||
sessionCtx?: { Provider?: string; ChatType?: string };
|
||||
sourceReplyDeliveryMode?: string;
|
||||
@@ -668,6 +670,34 @@ describe("runPreparedReply media-only handling", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("binds prepared embedded prompt variants without changing CLI session guidance", async () => {
|
||||
vi.mocked(buildDirectChatContext).mockImplementation(
|
||||
({ sourceReplyDeliveryMode }) => `direct:${sourceReplyDeliveryMode ?? "automatic"}`,
|
||||
);
|
||||
await runPrepared({
|
||||
opts: {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
sourceReplyDeliveryModeOrigin: "runtime_default",
|
||||
} as NonNullable<Parameters<typeof runPreparedReply>[0]["opts"]> &
|
||||
SourceReplyDeliveryRuntimeOptions,
|
||||
ctx: { ...createInboundTurn("hello", "discord", "direct") },
|
||||
sessionCtx: { ...createSessionTurn("hello", "discord", "direct") },
|
||||
});
|
||||
|
||||
const run = requireLastRunReplyAgentCall().followupRun.run;
|
||||
expect(run.extraSystemPrompt).toBe("direct:message_tool_only");
|
||||
expect(run.extraSystemPromptStatic).toBe("direct:message_tool_only");
|
||||
publishPreparedHarnessSourceReplyDeliveryMode(run, "automatic");
|
||||
expect(run.extraSystemPrompt).toBe("direct:automatic");
|
||||
expect(run.extraSystemPromptStatic).toBe("direct:message_tool_only");
|
||||
publishPreparedHarnessSourceReplyDeliveryMode(run, "message_tool_only");
|
||||
expect(run.extraSystemPrompt).toBe("direct:message_tool_only");
|
||||
expect(run.cliSessionBindingFacts).toEqual({
|
||||
extraSystemPromptStatic: "direct:message_tool_only",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps addressed message-tool delivery hints out of persisted transcript rows", async () => {
|
||||
vi.mocked(buildInboundUserContextPrefix).mockReturnValueOnce(
|
||||
"Current message:\nchat_id=-100123\ninbound_event_kind: user_request",
|
||||
@@ -2849,6 +2879,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
const directContextParams = requireMockCallArg(
|
||||
vi.mocked(buildDirectChatContext),
|
||||
"direct chat context",
|
||||
1,
|
||||
) as { sourceReplyDeliveryMode?: string };
|
||||
const call = requireLastRunReplyAgentCall();
|
||||
expect(directContextParams?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
@@ -2953,7 +2984,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
});
|
||||
|
||||
const call = requireLastRunReplyAgentCall();
|
||||
expect(buildGroupChatContext).toHaveBeenCalledTimes(1);
|
||||
expect(buildGroupChatContext).toHaveBeenCalledTimes(2);
|
||||
const groupContextParams = requireMockCallArg(
|
||||
vi.mocked(buildGroupChatContext),
|
||||
"group chat context",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
useOpenAIPlatformAuthFixture,
|
||||
} from "../../agents/embedded-agent-runner/run.overflow-compaction.harness.js";
|
||||
import { buildEmbeddedSystemPrompt } from "../../agents/embedded-agent-runner/system-prompt.js";
|
||||
import { registerAgentHarness } from "../../agents/harness/registry.js";
|
||||
import { settleReplyDispatcher } from "../dispatch-dispatcher.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
@@ -27,8 +28,12 @@ import {
|
||||
setNoAbort,
|
||||
} from "./dispatch-from-config.test-harness.js";
|
||||
import type { InternalGetReplyOptions } from "./get-reply.types.js";
|
||||
import { buildDirectChatContext } from "./groups.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { setSourceReplyDeliveryModeOrigin } from "./source-reply-delivery-runtime.js";
|
||||
import {
|
||||
setSourceReplyDeliveryModeOrigin,
|
||||
setSourceReplyDeliveryPromptVariants,
|
||||
} from "./source-reply-delivery-runtime.js";
|
||||
import { buildTestCtx } from "./test-ctx.js";
|
||||
|
||||
const runnerState = setupAgentRunnerExecutionTestState();
|
||||
@@ -102,8 +107,34 @@ describe("prepared harness source delivery", () => {
|
||||
modelOverride: "gpt-5.4",
|
||||
});
|
||||
const emittedStreamingCallbacks: string[] = [];
|
||||
let modelVisiblePrompt = "";
|
||||
const recordModelVisiblePrompt = (attemptParams: {
|
||||
extraSystemPrompt?: string;
|
||||
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
||||
}) => {
|
||||
modelVisiblePrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
reasoningTagHint: false,
|
||||
extraSystemPrompt: attemptParams.extraSystemPrompt,
|
||||
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
|
||||
runtimeInfo: {
|
||||
host: "host",
|
||||
os: "linux",
|
||||
arch: "arm64",
|
||||
node: "24",
|
||||
model: "model",
|
||||
provider: "custom",
|
||||
channel: "discord",
|
||||
chatType: "direct",
|
||||
},
|
||||
tools: [],
|
||||
userTimezone: "UTC",
|
||||
userDate: "2026-08-11",
|
||||
});
|
||||
};
|
||||
mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "Short fallback final" }]);
|
||||
mockedRunEmbeddedAttempt.mockImplementation(async (attemptParams) => {
|
||||
recordModelVisiblePrompt(attemptParams);
|
||||
emittedStreamingCallbacks.push("partial");
|
||||
await attemptParams.onPartialReply?.({ text: "Short fallback final" });
|
||||
emittedStreamingCallbacks.push("block");
|
||||
@@ -164,6 +195,7 @@ describe("prepared harness source delivery", () => {
|
||||
? { supported: true, priority: 200 }
|
||||
: { supported: false, reason: "prepared OpenAI route only" },
|
||||
runAttempt: vi.fn(async (attemptParams) => {
|
||||
recordModelVisiblePrompt(attemptParams);
|
||||
emittedStreamingCallbacks.push("partial");
|
||||
await attemptParams.onPartialReply?.({ text: "Short fallback final" });
|
||||
emittedStreamingCallbacks.push("block");
|
||||
@@ -201,6 +233,24 @@ describe("prepared harness source delivery", () => {
|
||||
followupRun.run.sessionKey = undefined;
|
||||
followupRun.run.sessionFile = followupRun.run.sessionId;
|
||||
followupRun.run.sourceReplyDeliveryMode = runtimeOpts.sourceReplyDeliveryMode;
|
||||
const extraSystemPromptBySourceReplyDeliveryMode = {
|
||||
automatic: buildDirectChatContext({
|
||||
sessionCtx: { Provider: "discord", ChatType: "direct" },
|
||||
sourceReplyDeliveryMode: "automatic",
|
||||
}),
|
||||
message_tool_only: buildDirectChatContext({
|
||||
sessionCtx: { Provider: "discord", ChatType: "direct" },
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
}),
|
||||
};
|
||||
setSourceReplyDeliveryPromptVariants(
|
||||
followupRun.run,
|
||||
extraSystemPromptBySourceReplyDeliveryMode,
|
||||
);
|
||||
followupRun.run.extraSystemPrompt =
|
||||
extraSystemPromptBySourceReplyDeliveryMode[
|
||||
runtimeOpts.sourceReplyDeliveryMode ?? "automatic"
|
||||
];
|
||||
setSourceReplyDeliveryModeOrigin(followupRun.run, runtimeOpts.sourceReplyDeliveryModeOrigin);
|
||||
// Dispatch already captured its session snapshot; the embedded fixture uses
|
||||
// a SQLite compatibility key and has no durable row for writer admission.
|
||||
@@ -286,5 +336,20 @@ describe("prepared harness source delivery", () => {
|
||||
});
|
||||
expect(dispatcher.getFailedCounts()).toEqual({ tool: 0, block: 0, final: 0 });
|
||||
expect(modeTransitions).toEqual(testCase.expectedTransitions);
|
||||
if (testCase.preparedVisibleReplies === "automatic") {
|
||||
expect(modelVisiblePrompt).toContain("Current-session final text normally routes to source");
|
||||
expect(modelVisiblePrompt).toContain(
|
||||
"Your replies are automatically sent to this conversation",
|
||||
);
|
||||
expect(modelVisiblePrompt).not.toContain("Normal final replies are private");
|
||||
} else {
|
||||
expect(modelVisiblePrompt).toContain(
|
||||
"Current source visible reply MUST use `message(action=send)`",
|
||||
);
|
||||
expect(modelVisiblePrompt).toContain("Normal final replies are private");
|
||||
expect(modelVisiblePrompt).not.toContain(
|
||||
"Your replies are automatically sent to this conversation",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ export type SourceReplyDeliveryRuntimeOptions = {
|
||||
};
|
||||
|
||||
// The shared enumerable binding follows queue/run spreads without widening their public types.
|
||||
// Its listener moves prepared ownership before live callbacks; copying only the mode would leave
|
||||
// pre-settlement source delivery on the preliminary policy.
|
||||
// Its listener and bounded prompt pair move prepared ownership before prompt/live callbacks;
|
||||
// plugin handoff strips this symbol so neither mutable authority nor alternate prompt leaks.
|
||||
const sourceReplyDeliveryModeOriginKey: unique symbol = Symbol.for(
|
||||
"openclaw.source-reply-delivery-runtime",
|
||||
);
|
||||
@@ -17,6 +17,7 @@ type SourceReplyDeliveryRuntimeBinding = {
|
||||
origin?: SourceReplyDeliveryModeOrigin;
|
||||
preparedHarnessMode?: SourceReplyDeliveryMode;
|
||||
preparedHarnessModeListener?: (mode: SourceReplyDeliveryMode) => void;
|
||||
extraSystemPromptByMode?: Record<SourceReplyDeliveryMode, string>;
|
||||
};
|
||||
type SourceReplyDeliveryModeOwner = {
|
||||
[sourceReplyDeliveryModeOriginKey]?: SourceReplyDeliveryRuntimeBinding;
|
||||
@@ -43,6 +44,15 @@ export function readSourceReplyDeliveryModeOrigin(
|
||||
return readSourceReplyDeliveryRuntimeBinding(owner)?.origin;
|
||||
}
|
||||
|
||||
export function setSourceReplyDeliveryPromptVariants(
|
||||
owner: object,
|
||||
variants: Record<SourceReplyDeliveryMode, string>,
|
||||
): void {
|
||||
const binding = readSourceReplyDeliveryRuntimeBinding(owner) ?? {};
|
||||
binding.extraSystemPromptByMode = variants;
|
||||
(owner as SourceReplyDeliveryModeOwner)[sourceReplyDeliveryModeOriginKey] = binding;
|
||||
}
|
||||
|
||||
export function copySourceReplyDeliveryRuntimeBinding(source: object, target: object): void {
|
||||
const binding = readSourceReplyDeliveryRuntimeBinding(source);
|
||||
if (binding) {
|
||||
@@ -57,6 +67,10 @@ export function publishPreparedHarnessSourceReplyDeliveryMode(
|
||||
const binding = readSourceReplyDeliveryRuntimeBinding(owner);
|
||||
if (binding?.origin === "runtime_default") {
|
||||
binding.preparedHarnessMode = mode;
|
||||
const extraSystemPrompt = binding.extraSystemPromptByMode?.[mode];
|
||||
if (extraSystemPrompt !== undefined) {
|
||||
(owner as { extraSystemPrompt?: string }).extraSystemPrompt = extraSystemPrompt;
|
||||
}
|
||||
binding.preparedHarnessModeListener?.(mode);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user