默认只开主世界层,无转述时直接把原文当终稿呈现。

避免再经转述改写一轮,并让主世界层在关转述时走可见正文协议。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 03:06:38 +08:00
co-authored by Cursor
parent 5574b0d08e
commit b871578d59
10 changed files with 96 additions and 38 deletions
+11 -2
View File
@@ -120,7 +120,7 @@ import {
SLOT_ASKED_QUESTIONS,
} from "../skills/question-protocol.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { defaultPlaySlots } from "../skills/play-slots.js";
import { defaultPlaySlots, isNarratorEnabled } from "../skills/play-slots.js";
import {
mergeOpeningTablePatch,
OPENING_CURRENT_VARS_TAG,
@@ -1252,7 +1252,16 @@ export class PhaseRuntime {
): import("../worker/executor.js").WorkerRunResult {
if (!isPlayLayerActive(this.session.slots)) return result;
const outputs = { ...result.outputs };
if (Object.keys(outputs).length === 0 && isPlayPresentWorker(workerId)) {
if (
Object.keys(outputs).length === 0 &&
isPlayPresentWorker(workerId, {
narratorEnabled: isNarratorEnabled(
parseWorkerSetYaml(
this.blackboard.getContentByTag("设计.worker集") ?? "",
)?.play_slots,
),
})
) {
const shell =
parseShellAdaptationFromReplyFormat(
this.blackboard.getContentByTag("设计.正文组成") ??
+2
View File
@@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
"设计.变量设计与更新规则",
"设计.生成规则",
"设计.叙事指南与故事推进",
"设计.正文组成",
"设计.监控栏",
],
dynamic: [
"变量.当前",
+4 -1
View File
@@ -32,6 +32,7 @@ import {
} from "./context-order.js";
import type { ContextSegmentDef } from "./context-segments.js";
import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js";
import { isNarratorEnabled } from "./play-slots.js";
type WorkerTemplateDoc = {
id?: string;
@@ -220,7 +221,9 @@ export function buildDeclaredWorkerSkill(params: {
`执行 ${id}`;
const excerpt = params.template?.prompt_excerpt?.trim() || "";
const visibleBodyRule =
id === "narrator" || id === "round-present"
id === "narrator" ||
id === "round-present" ||
(id === "world-simulator" && !isNarratorEnabled(params.workerSet?.play_slots))
? PLAY_VISIBLE_BODY_INSTRUCTION
: "";
const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n");
+30 -18
View File
@@ -11,13 +11,13 @@ export type OnDemandSlotId = "chance";
export type PlaySlotsConfig = {
/**
* 旁观维护(副 LLM:表/规则检查,默认空操作);默认 true。
* 每轮可上场,但不等于每轮改表。缺省键视为开启。
* 旁观维护(副 LLM:表/规则检查,默认空操作);默认 false。
* 每轮可上场,但不等于每轮改表。缺省键视为关闭。
*/
auditor?: boolean;
/** 主世界层(裁决);默认 true */
/** 主世界层(裁决 / 无转述时兼写可见正文);默认 true */
gm: boolean;
/** 叙事转述;默认 true */
/** 叙事转述;默认 false(主世界层直接交用户可见原文) */
narrator: boolean;
/** 单角色知密视角;默认 false */
perspective: boolean;
@@ -53,12 +53,13 @@ export const PLAY_SLOT_META: Record<
},
gm: {
label: "主世界层",
purpose: "读真值与 Progressive 投影,输出结构化裁决包;可提议变量变更。",
defaultAcceptance: "continue",
purpose:
"读真值与 Progressive 投影并推进世界;无独立转述时直接写用户可见正文,有转述时出裁决包。",
defaultAcceptance: "review",
},
narrator: {
label: "叙事转述",
purpose: "只读裁决包 + 文风常驻,输出用户可见正文。",
purpose: "只读裁决包 + 文风常驻,输出用户可见正文(可选;默认关)。",
defaultAcceptance: "review",
},
perspective: {
@@ -102,14 +103,19 @@ export function playSlotIdForRef(ref: string): PlaySlotId | undefined {
export function defaultPlaySlots(): PlaySlotsConfig {
return {
auditor: true,
auditor: false,
gm: true,
narrator: true,
narrator: false,
perspective: false,
chance: false,
};
}
/** 是否启用独立转述层(未显式 true 视为关) */
export function isNarratorEnabled(slots: PlaySlotsConfig | undefined | null): boolean {
return slots?.narrator === true;
}
export function parsePlaySlots(raw: unknown): PlaySlotsConfig | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
@@ -154,10 +160,7 @@ export function refForSlot(
}
export function enabledPlaySlotIds(slots: PlaySlotsConfig): PlaySlotId[] {
return PLAY_SLOT_ORDER.filter((id) => {
if (id === "auditor") return slots.auditor !== false;
return Boolean(slots[id]);
});
return PLAY_SLOT_ORDER.filter((id) => Boolean(slots[id]));
}
export function enabledOnDemandSlotIds(slots: PlaySlotsConfig): OnDemandSlotId[] {
@@ -188,14 +191,19 @@ export function workerEntryForSlot(
): WorkerSetEntry {
const meta = PLAY_SLOT_META[id];
const ref = refForSlot(slots, id);
const soloGm = id === "gm" && !isNarratorEnabled(slots);
return {
ref,
name: meta.label,
role: id,
duty: meta.purpose,
duty: soloGm
? "读真值与投影,推进世界,并直接写出用户可见正文(无独立转述层,原文即终稿)。"
: meta.purpose,
when:
id === "gm"
? "每轮用户输入后首先上场(主 LLM / 裁决权威)"
? soloGm
? "每轮用户输入后上场;直接交用户可见正文"
: "每轮用户输入后首先上场(主 LLM / 裁决权威)"
: id === "perspective"
? "强信息隔离且本轮需要该角色独立反应时(主世界层之后)"
: id === "narrator"
@@ -203,13 +211,17 @@ export function workerEntryForSlot(
: "转述完成后;表维护合并进变量.当前,供下一轮主世界层读取",
rationale:
id === "gm"
? "等同 SillyTavern 主 LLM:读设定与状态并产出裁决"
? soloGm
? "默认少槽:主世界层兼呈现,避免转述再改写一遍"
: "有独立转述时:主世界层出裁决,正文交给转述"
: id === "perspective"
? "知密内容不能进主世界层上下文"
: id === "narrator"
? "用户可见正文与裁决分离,避免文风与规则互相挤压"
: "表与规则补充延后执行,改表结果作用于下一轮而非本轮重裁",
acceptance: meta.defaultAcceptance,
acceptance:
id === "gm" && isNarratorEnabled(slots) ? "continue" : meta.defaultAcceptance,
outputs: soloGm ? ["输出.用户展示"] : undefined,
invocation: "turn",
};
}
@@ -356,7 +368,7 @@ export function formatPlayAgentRosterForPrompt(): string {
];
for (const id of PLAY_SLOT_ORDER) {
const meta = PLAY_SLOT_META[id];
const def = id === "perspective" ? "默认关" : "默认开";
const def = id === "gm" ? "默认开" : "默认关";
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`,
);
+8 -3
View File
@@ -198,10 +198,15 @@ export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
/** 游玩期把裁决写成用户可见终稿的执行单元 */
export function isPlayPresentWorker(workerId: string): boolean {
/** 游玩期把用户可见终稿写出的执行单元(有转述用转述;无则主世界层直接交原文) */
export function isPlayPresentWorker(
workerId: string,
opts?: { narratorEnabled?: boolean },
): boolean {
const id = workerId.trim();
return id === "narrator" || id === "round-present";
if (id === "narrator" || id === "round-present") return true;
if (id === "world-simulator" && opts?.narratorEnabled === false) return true;
return false;
}
/** 按能力探测走 json_schema / forced_tool 时用的 present.v1 形状(全 required,可 strict) */
+5 -5
View File
@@ -236,6 +236,9 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"世界.蓝图.确认稿",
"世界.拓扑.*",
"设计.变量设计与更新规则",
"设计.叙事指南与故事推进",
"设计.正文组成",
"设计.监控栏",
],
dynamic: [
"变量.当前",
@@ -246,7 +249,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"运行.本轮.旁观",
],
},
outputs: ["运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
outputs: ["输出.用户展示", "运行.本轮.裁决", "运行.事件流", "运行.本轮.变量变更"],
},
"variable-update": {
context: {
@@ -767,10 +770,7 @@ export function formatWorkerSetForUser(
? PLAY_SLOT_ORDER.map((id: PlaySlotId) => ({
id,
label: PLAY_SLOT_META[id].label,
enabled:
id === "auditor"
? parsed.play_slots!.auditor !== false
: Boolean(parsed.play_slots![id]),
enabled: Boolean(parsed.play_slots![id]),
ref: refForSlot(parsed.play_slots!, id),
}))
: undefined,
+10 -6
View File
@@ -46,6 +46,7 @@ import {
GM_CHANCE_HARNESS_INSTRUCTION,
} from "../skills/gm-tools.js";
import { runGmHarness } from "./gm-harness.js";
import { isNarratorEnabled } from "../skills/play-slots.js";
import {
mergeQuestionsPreferFragment,
normalizeQuestions,
@@ -772,17 +773,20 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
}
const playSlots = parseWorkerSetYaml(
params.blackboard.getContentByTag("设计.worker集") ?? "",
)?.play_slots;
const useGmHarness =
isPlayLayerActive(params.slots) &&
worker.id === "world-simulator" &&
gmChanceToolsEnabled(
parseWorkerSetYaml(
params.blackboard.getContentByTag("设计.worker集") ?? "",
)?.play_slots,
);
gmChanceToolsEnabled(playSlots);
const playPresent =
isPlayLayerActive(params.slots) && isPlayPresentWorker(worker.id);
isPlayLayerActive(params.slots) &&
isPlayPresentWorker(worker.id, {
narratorEnabled: isNarratorEnabled(playSlots),
});
const playShell: PresentShellId | undefined = playPresent
? parseShellAdaptationFromReplyFormat(
params.blackboard.getContentByTag("设计.正文组成") ??
+18 -1
View File
@@ -34,8 +34,25 @@ import { synthesizeContextOrderFromWorkers } from "../src/skills/context-order.j
import { DIALOGUE_HISTORY_TAG } from "../src/skills/dialogue-history.js";
describe("play_slots", () => {
it("expands default auditor+gm+narrator", () => {
it("defaults to gm only", () => {
const slots = parsePlaySlots({})!;
expect(slots).toEqual({
auditor: false,
gm: true,
narrator: false,
perspective: false,
chance: false,
});
expect(refsFromPlaySlots(slots)).toEqual(["world-simulator"]);
const workers = expandPlaySlotsToWorkers(slots);
expect(workers.map((w) => w.ref)).toEqual(["world-simulator"]);
expect(workers[0].acceptance).toBe("review");
expect(workers[0].outputs).toEqual(["输出.用户展示"]);
});
it("expands auditor+gm+narrator when all enabled", () => {
const slots = parsePlaySlots({
auditor: true,
gm: true,
narrator: true,
perspective: false,
+7 -1
View File
@@ -118,10 +118,16 @@ describe("shell adaptation from reply-format", () => {
});
describe("play present worker", () => {
it("recognizes narrator and round-present", () => {
it("recognizes narrator, round-present, and solo gm", () => {
expect(isPlayPresentWorker("narrator")).toBe(true);
expect(isPlayPresentWorker("round-present")).toBe(true);
expect(isPlayPresentWorker("world-simulator")).toBe(false);
expect(
isPlayPresentWorker("world-simulator", { narratorEnabled: false }),
).toBe(true);
expect(
isPlayPresentWorker("world-simulator", { narratorEnabled: true }),
).toBe(false);
});
it("fallback packet uses the given shell", () => {
+1 -1
View File
@@ -629,7 +629,7 @@ function shouldRenderPlayPresent(msg, view) {
if (isPlayHousekeepingMessage(msg)) return false;
const actor = String(msg.actor ?? "");
const title = String(msg.title ?? "");
return actor === "narrator" || actor === "round-present" || /用户展示|开场白/.test(title);
return actor === "narrator" || actor === "round-present" || actor === "world-simulator" || /用户展示|开场白/.test(title);
}
function isPlayFinalReply(msg, view) {