统一在预设包装层做提示词夹心,并修正探针实际发信路径。
让所有模型请求与界面预览共用同一套夹心,避免探针与运行时行为分叉。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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. 第一版导入策略
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user