统一在预设包装层做提示词夹心,并修正探针实际发信路径。

让所有模型请求与界面预览共用同一套夹心,避免探针与运行时行为分叉。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 02:18:00 +08:00
co-authored by Cursor
parent beb4c332e6
commit d65ebbce8b
7 changed files with 252 additions and 32 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ Runtime Session
因此,同一个 preset 可以用于多个创作流程;同一个创作流程也可以切换不同 preset。二者是正交关系。
生成参数可全局合并进 LLM 请求。**prompt 条目 + marker 填洞只在 play worker 上用**:创作步 / 总管 tool loop 不得把角色卡骨架套到 design-step 上,也不得在填洞后再叠一份相同的 user 上下文。
生成参数与 **prompt 条目 + marker 填洞** 均在 `wrapLlmForSession`(`PresetLlmProvider`)统一做:凡 LLM 请求先夹心再发。调用方只交任务 messages;任务 system / 创作上下文进 `worldBookBefore` 等洞,**禁止**在 `prompt_order` 之外再前置一条 system。无选用预设时原样转发。
## 6. 第一版导入策略
+12 -8
View File
@@ -1,4 +1,5 @@
import { loadAppSettings } from "../config/settings.js";
import { frameMessagesWithPreset } from "../preset/play-frame.js";
import { resolveActivePreset } from "../preset/store.js";
import type { PresetPackage } from "../types/preset.js";
import type {
@@ -23,7 +24,7 @@ function readActivePreset(): PresetPackage | null {
/**
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
* 当前没有选用预设时仍包一层,请求原样转发。
* 有选用预设时:在此按 prompt_order 夹心包裹,并合并 generation。
*/
export function wrapLlmForSession(
inner: LlmProvider,
@@ -36,10 +37,7 @@ export function wrapLlmForSession(
return new PresetLlmProvider(tracked, getPreset);
}
/**
* 在所有 LLM 请求上合并当前 preset 的生成参数。
* prompt / marker 不在此前置,由 play worker 按扩展 prompt_order 填洞。
*/
/** 所有 LLM 请求:preset 夹心 + 生成参数。无选用预设则原样转发。 */
export class PresetLlmProvider implements LlmProvider {
constructor(
private readonly inner: LlmProvider,
@@ -52,9 +50,8 @@ export class PresetLlmProvider implements LlmProvider {
): { messages: ChatMessage[]; options: CompleteOptions | undefined } {
const preset = this.getPreset();
if (!preset) return { messages, options };
// 生成参数全局合并;prompt/marker 只在 play worker 按 prompt_order 填洞,这里不再前置。
return {
messages,
messages: frameMessagesWithPreset(preset, messages),
options: {
...options,
generation: options?.generation ?? preset.generation,
@@ -97,6 +94,8 @@ export class PresetLlmProvider implements LlmProvider {
return this.inner.completeWithTools(prepared.messages, {
...options,
...prepared.options,
tools: options.tools,
toolChoice: options.toolChoice,
});
}
@@ -111,7 +110,12 @@ export class PresetLlmProvider implements LlmProvider {
const prepared = this.prepare(messages, options);
return this.inner.completeWithToolsStream(
prepared.messages,
{ ...options, ...prepared.options },
{
...options,
...prepared.options,
tools: options.tools,
toolChoice: options.toolChoice,
},
callbacks,
);
}
+99 -5
View File
@@ -4,12 +4,32 @@ import { assemblePresetMessages } from "./assembler.js";
import { applyExtendedMarkers } from "./markers.js";
import {
resolveWorldInfoMarker,
type WorldInfoEntry,
type WorldInfoPack,
} from "./world-info-pack.js";
const TASK_SYSTEM_ENTRY_ID = "task-system";
const TASK_SYSTEM_ENTRY_NAME = "任务契约";
function withTaskSystem(
pack: WorldInfoPack,
systemPrompt: string,
): WorldInfoPack {
const text = systemPrompt.trim();
if (!text) return pack;
const entry: WorldInfoEntry = {
id: TASK_SYSTEM_ENTRY_ID,
name: TASK_SYSTEM_ENTRY_NAME,
content: text,
};
return {
...pack,
worldBookBefore: [entry, ...pack.worldBookBefore],
};
}
/**
* Play worker:system 契约 + 按扩展 prompt_order 填洞。
* 不要再把同一份 context-order 正文叠成第二包 user。
* 严格按扩展 prompt_order 填洞。任务契约进 worldBookBefore,不在预设外另挂 system。
*/
export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
@@ -17,8 +37,82 @@ export function assemblePlayWorkerMessages(params: {
pack: WorldInfoPack;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const filled = assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(params.pack, id),
const pack = withTaskSystem(params.pack, params.systemPrompt);
return assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(pack, id),
);
return [{ role: "system", content: params.systemPrompt }, ...filled];
}
function isToolLoopBoundary(m: ChatMessage): boolean {
if (m.role === "tool") return true;
return Boolean(
m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0,
);
}
function formatHistoryMessages(msgs: ChatMessage[]): string {
const parts: string[] = [];
for (const m of msgs) {
if (m.role === "user" && m.content.trim()) {
parts.push(`【用户】\n${m.content.trim()}`);
} else if (m.role === "assistant") {
const text = (m.content ?? "").trim();
if (text) parts.push(`【助手】\n${text}`);
} else if (m.role === "system" && m.content.trim()) {
parts.push(`【系统】\n${m.content.trim()}`);
}
}
return parts.join("\n\n");
}
/**
* 任务 messages → 只填四袋 + chatHistory;发出序列的首尾由预设 prompt_order 决定。
* tool 循环尾接在夹心后。
*/
export function frameMessagesWithPreset(
preset: PresetPackage,
messages: ChatMessage[],
): ChatMessage[] {
let splitAt = messages.length;
for (let i = 0; i < messages.length; i++) {
if (isToolLoopBoundary(messages[i]!)) {
splitAt = i;
break;
}
}
const prefix = messages.slice(0, splitAt);
const continuation = messages.slice(splitAt);
const systems: string[] = [];
const body: ChatMessage[] = [];
for (const m of prefix) {
if (m.role === "system") systems.push(m.content);
else body.push(m);
}
let turn = "";
let history = "";
if (body.length > 0) {
const last = body[body.length - 1]!;
if (last.role === "user") {
turn = last.content.trim();
history = formatHistoryMessages(body.slice(0, -1));
} else {
history = formatHistoryMessages(body);
}
}
const framed = assemblePlayWorkerMessages({
systemPrompt: systems.join("\n\n"),
preset,
pack: {
worldBookBefore: [],
history,
worldBookAfter: [],
turn,
postTurn: [],
},
});
return continuation.length > 0 ? [...framed, ...continuation] : framed;
}
+33 -6
View File
@@ -62,6 +62,7 @@ export function worldInfoPackFromProbeInput(
};
}
/** 已夹心的试跑 messages(仅 UI / 对照);真正发模型走任务消息 + PresetLlmProvider。 */
export function assemblePresetProbe(
preset: PresetPackage,
input: PresetProbeInput,
@@ -73,6 +74,32 @@ export function assemblePresetProbe(
});
}
/** 交给 wrapLlmForSession 的任务消息(未夹心)。 */
export function buildPresetProbeTaskMessages(
input: PresetProbeInput,
): ChatMessage[] {
const pack = worldInfoPackFromProbeInput(input);
const parts: string[] = [];
for (const e of pack.worldBookBefore) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
if (pack.history.trim()) parts.push(pack.history.trim());
for (const e of pack.worldBookAfter) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
for (const e of pack.postTurn) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
const ctx = parts.join("\n\n").trim();
const user = ctx
? `${ctx}\n\n## 本轮输入\n${pack.turn}`
: pack.turn;
return [
{ role: "system", content: PROBE_SYSTEM_PROMPT },
{ role: "user", content: user },
];
}
export function serializeProbeMessages(
messages: ChatMessage[],
): PresetProbeMessage[] {
@@ -94,11 +121,11 @@ export async function runPresetProbe(params: {
throw new Error("缺少本轮输入");
}
const messages = assemblePresetProbe(params.preset, {
...params.input,
message,
});
const serialized = serializeProbeMessages(messages);
const input = { ...params.input, message };
// UI 仍展示「理想分袋夹心」;发模型只交任务消息,由统一包装层夹心
const serialized = serializeProbeMessages(
assemblePresetProbe(params.preset, input),
);
if (!params.complete) {
return {
@@ -111,7 +138,7 @@ export async function runPresetProbe(params: {
}
try {
const result = await params.complete(messages);
const result = await params.complete(buildPresetProbeTaskMessages(input));
return {
messages: serialized,
reply: result.content,
+55 -2
View File
@@ -51,9 +51,11 @@ describe("assemblePlayWorkerMessages", () => {
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "SKILL"],
["system", "MAIN"],
["system", "### 舞台\n\n古镇夜雨"],
[
"system",
"### 任务契约\n\nSKILL\n\n### 舞台\n\n古镇夜雨",
],
["system", "用户:昨天来过"],
["system", "### 变量\n\nHP 3"],
["user", "我推门"],
@@ -62,6 +64,57 @@ describe("assemblePlayWorkerMessages", () => {
]);
});
it("does not emit any message before the first preset order entry", () => {
const report = importSillyTavernPreset({
prompts: [
{
identifier: "wb-head",
name: "世界书首",
role: "system",
content: "<世界书>",
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{
identifier: "wb-tail",
name: "世界书尾",
role: "system",
content: "</世界书>",
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "wb-head", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "wb-tail", enabled: true },
],
},
],
});
const messages = assemblePlayWorkerMessages({
systemPrompt: "创作步骤契约",
preset: report.preset,
pack: {
worldBookBefore: [{ id: "lore", name: "设定", content: "港口" }],
history: "昨日对话",
worldBookAfter: [],
turn: "本轮",
postTurn: [],
},
});
expect(messages[0]).toEqual({ role: "system", content: "<世界书>" });
expect(messages.some((m) => m.content === "创作步骤契约")).toBe(false);
expect(
messages.some((m) => m.content.includes("### 任务契约\n\n创作步骤契约")),
).toBe(true);
});
it("does not fill ST charDescription even if resolver returns content", () => {
const report = importSillyTavernPreset({
prompts: [
+5 -3
View File
@@ -46,9 +46,11 @@ describe("assemblePresetProbe", () => {
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", PROBE_SYSTEM_PROMPT],
["system", "MAIN"],
["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`],
[
"system",
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}\n\n### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`,
],
["system", DEFAULT_PROBE_CONTEXT.history],
["system", `### 本轮状态\n\n${DEFAULT_PROBE_CONTEXT.loreAfter}`],
["user", "我推开门。"],
@@ -66,8 +68,8 @@ describe("assemblePresetProbe", () => {
});
expect(messages.map((m) => m.content)).toEqual([
PROBE_SYSTEM_PROMPT,
"MAIN",
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}`,
"你好",
"PHI",
]);
+47 -7
View File
@@ -53,7 +53,7 @@ function recordingProvider(): {
}
describe("wrapLlmForSession", () => {
it("context trace matches the exact messages and generation sent to the inner LLM", async () => {
it("frames task messages with preset prompt_order and merges generation", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
@@ -64,14 +64,15 @@ describe("wrapLlmForSession", () => {
);
expect(received).toHaveLength(1);
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(received[0].messages.map((m) => [m.role, m.content])).toEqual([
["system", "PRESET_RULES"],
["user", "TASK"],
]);
expect(received[0].generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
});
expect(ctx.pendingContextTrace?.messages).toEqual([
{ role: "user", content: "TASK" },
]);
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
expect(ctx.pendingContextTrace?.generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
@@ -80,7 +81,44 @@ describe("wrapLlmForSession", () => {
expect(ctx.pendingContextTrace?.model).toBe("rec-model");
});
it("completeStream also records the merged request", async () => {
it("puts task system inside worldBookBefore, not before the preset shell", async () => {
const { provider, received } = recordingProvider();
const llm = wrapLlmForSession(provider, undefined, () => samplePreset);
await llm.completeWithTools(
[
{ role: "system", content: "SKILL" },
{ role: "user", content: "do it" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "1",
type: "function",
function: { name: "t", arguments: "{}" },
},
],
},
{ role: "tool", content: "{}", tool_call_id: "1" },
],
{ tools: [], caller: "dictate_agent" },
);
expect(received[0].messages[0].content).toBe("PRESET_RULES");
expect(
received[0].messages.some(
(m) => m.content === "### 任务契约\n\nSKILL",
),
).toBe(true);
expect(received[0].messages.some((m) => m.content === "do it")).toBe(true);
expect(received[0].messages.map((m) => m.role).slice(-2)).toEqual([
"assistant",
"tool",
]);
});
it("completeStream also records the framed request", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
@@ -93,7 +131,9 @@ describe("wrapLlmForSession", () => {
);
expect(chunks.join("")).toBe("ok");
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(received[0].messages.some((m) => m.content === "PRESET_RULES")).toBe(
true,
);
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
});
});