diff --git a/docs/preset-format.md b/docs/preset-format.md index 5aafc4b..816eae3 100644 --- a/docs/preset-format.md +++ b/docs/preset-format.md @@ -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. 第一版导入策略 diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index aa89b38..fadff57 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -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, ); } diff --git a/src/preset/play-frame.ts b/src/preset/play-frame.ts index a65d6ab..02db33c 100644 --- a/src/preset/play-frame.ts +++ b/src/preset/play-frame.ts @@ -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; } diff --git a/src/preset/probe.ts b/src/preset/probe.ts index 4a2d450..669b881 100644 --- a/src/preset/probe.ts +++ b/src/preset/probe.ts @@ -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, diff --git a/tests/play-frame.test.ts b/tests/play-frame.test.ts index c0e7736..27314c6 100644 --- a/tests/play-frame.test.ts +++ b/tests/play-frame.test.ts @@ -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: [ diff --git a/tests/preset-probe.test.ts b/tests/preset-probe.test.ts index 4bd97f4..8c31c21 100644 --- a/tests/preset-probe.test.ts +++ b/tests/preset-probe.test.ts @@ -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", ]); diff --git a/tests/preset-wrapper.test.ts b/tests/preset-wrapper.test.ts index 06eee66..9968070 100644 --- a/tests/preset-wrapper.test.ts +++ b/tests/preset-wrapper.test.ts @@ -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); }); });