diff --git a/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml index bb98bfa..c3e87d0 100644 --- a/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml +++ b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml @@ -4,7 +4,7 @@ role: core duty: > 中立世界层:读用户输入、真值与 Progressive 投影,按规则推进并产出结构化裁决包 (settlement.v1 → 运行.本轮.裁决);可提议变量变更;默认不写文学正文。 -when: 每轮用户输入后 +when: 每轮用户输入后首先上场 suggested_context: static: - 设计.worker集 @@ -14,7 +14,6 @@ suggested_context: - 用户.最新输入 - 运行.事件流 - 变量.当前 - - 运行.本轮.旁观 - 上下文.角色态度 - 大纲.当前章 suggested_outputs: @@ -22,11 +21,10 @@ suggested_outputs: - 运行.事件流 - 运行.本轮.变量变更 prompt_excerpt: | - 你是中立主世界层(GM),不是文学作者。读真值、已投影上下文,以及本轮旁观维护包(若有)。 + 你是中立主世界层(GM),不是文学作者。读真值、已投影上下文与对话历史。 对用户行动作可复核裁决。「运行.本轮.裁决」必须是 JSON,schema 为 settlement.v1,至少尽量包含: player_action、resolved[]、visible_now、npc_moves[]、variable_changes[]、do_not_say[]、tone_hint。 variable_changes 由 Runtime 合并进变量.当前——请写清 key 与 to/delta。 叙事质量 intentionally 低——用户正文交给 narrator。 - 需要真随机(骰子/抽签/比点)时:不要自己编结果;依赖已启用的机遇裁定(chance) - 写出的「运行.本轮.机遇」,或由编排器先 run_worker chance。 + 需要真随机时:调用 chance 工具,在 requests 数组中一次性提交全部掷骰/抽取(每项带 id);禁止口算。 不要把未投影的分档长文整表塞进输出;只写本轮可见后果。 diff --git a/src/runtime/driver.ts b/src/runtime/driver.ts index 87d5b1a..2172f7e 100644 --- a/src/runtime/driver.ts +++ b/src/runtime/driver.ts @@ -37,6 +37,7 @@ export type DriverRunInput = { maxIterations?: number; onThinkingDelta?: (delta: string) => void; onThinkingDone?: (text: string) => void; + onOutputDelta?: (delta: string) => void; handleStep: ( calls: DriverToolCall[], ) => DriverStepOutcome | Promise; @@ -109,6 +110,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver { ]; const streamCallbacks: StreamCallbacks = { onReasoningDelta: (delta) => input.onThinkingDelta?.(delta), + onContentDelta: (delta) => input.onOutputDelta?.(delta), }; const label = input.label ?? "driver"; diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index d2df57b..4863f5f 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -108,6 +108,8 @@ import { import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js"; import { executeChance, + executeChanceBatch, + resolveChanceBatchRequest, resolveChanceRequest, } from "../skills/chance-tools.js"; import { @@ -1412,21 +1414,31 @@ export class PhaseRuntime { ).session; const boardRaw = this.blackboard.getContentByTag("运行.机会请求"); - const request = resolveChanceRequest({ + const batch = resolveChanceBatchRequest({ workerContext: effect.workerContext ?? null, blackboardRequestJson: boardRaw, }); - const result = request - ? executeChance(request) - : { - schema: "chance.v1" as const, - op: "roll" as const, - ok: false, - summary: "机遇失败:缺少请求(workerContext.chance 或 运行.机会请求)", - detail: {}, - error: "missing_request", - }; + const result = batch?.length + ? batch.length === 1 + ? executeChance(batch[0]!) + : executeChanceBatch(batch) + : (() => { + const single = resolveChanceRequest({ + workerContext: effect.workerContext ?? null, + blackboardRequestJson: boardRaw, + }); + if (single) return executeChance(single); + return { + schema: "chance.v1" as const, + op: "roll" as const, + ok: false, + summary: + "机遇失败:缺少请求(workerContext.chance.requests 或 运行.机会请求)", + detail: {}, + error: "missing_request", + }; + })(); const content = JSON.stringify(result, null, 2); const written = this.writeWorkerTagContent( diff --git a/src/skills/chance-tools.ts b/src/skills/chance-tools.ts index 0e9cb35..cc94e0c 100644 --- a/src/skills/chance-tools.ts +++ b/src/skills/chance-tools.ts @@ -52,6 +52,19 @@ export type ChanceResult = { error?: string; }; +export const CHANCE_BATCH_SCHEMA = "chance.batch.v1" as const; + +export type ChanceBatchRequestItem = ChanceRequest & { id: string }; + +export type ChanceBatchResultItem = ChanceResult & { id: string }; + +export type ChanceBatchResult = { + schema: typeof CHANCE_BATCH_SCHEMA; + ok: boolean; + results: ChanceBatchResultItem[]; + summary: string; +}; + const DICE_RE = /^\s*(\d*)\s*[dD]\s*(\d+)\s*([+-]\s*\d+)?\s*$/; function randInt(min: number, max: number): number { @@ -305,9 +318,100 @@ function executePick(req: ChancePickRequest, reason?: string): ChanceResult { } /** 从黑板正文或 workerContext 解析请求 */ +export function parseChanceBatchRequest( + raw: unknown, +): ChanceBatchRequestItem[] | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const listRaw = row.requests ?? row.batch ?? row.items; + if (!Array.isArray(listRaw)) return null; + const out: ChanceBatchRequestItem[] = []; + for (const item of listRaw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const entry = item as Record; + const id = + typeof entry.id === "string" + ? entry.id.trim() + : typeof entry.name === "string" + ? entry.name.trim() + : ""; + if (!id) continue; + const { id: _drop, name: _n, ...rest } = entry; + const req = parseChanceRequest(rest); + if (!req) continue; + out.push({ ...req, id }); + } + return out.length ? out : null; +} + +/** 一次性执行多条随机请求;每项必须有稳定 id 供裁决引用 */ +export function executeChanceBatch( + items: ChanceBatchRequestItem[], +): ChanceBatchResult { + const results: ChanceBatchResultItem[] = items.map((item) => { + const { id, ...req } = item; + const result = executeChance(req); + return { ...result, id }; + }); + const ok = results.every((r) => r.ok); + const summary = results.map((r) => `${r.id}: ${r.summary}`).join(";"); + return { + schema: CHANCE_BATCH_SCHEMA, + ok, + results, + summary: ok ? summary : `部分失败:${summary}`, + }; +} + +/** 从黑板正文或 workerContext 解析请求(单条或批量) */ export function resolveChanceRequest(params: { workerContext?: Record | null; blackboardRequestJson?: string | null; +}): ChanceRequest | null { + const batch = resolveChanceBatchRequest(params); + if (batch?.length === 1) { + const { id: _id, ...single } = batch[0]!; + return single; + } + return resolveChanceSingleRequest(params); +} + +/** 批量解析:workerContext / 黑板 JSON */ +export function resolveChanceBatchRequest(params: { + workerContext?: Record | null; + blackboardRequestJson?: string | null; +}): ChanceBatchRequestItem[] | null { + const ctx = params.workerContext; + if (ctx && typeof ctx === "object") { + const nested = ctx.chance ?? ctx.request ?? ctx; + const batch = parseChanceBatchRequest(nested); + if (batch) return batch; + const single = parseChanceRequest(nested); + if (single) { + const id = + typeof (nested as Record).id === "string" + ? String((nested as Record).id).trim() + : "chance-1"; + return [{ ...single, id: id || "chance-1" }]; + } + } + const raw = params.blackboardRequestJson?.trim(); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + const batch = parseChanceBatchRequest(parsed); + if (batch) return batch; + const single = parseChanceRequest(parsed); + if (single) return [{ ...single, id: "chance-1" }]; + } catch { + return null; + } + return null; +} + +function resolveChanceSingleRequest(params: { + workerContext?: Record | null; + blackboardRequestJson?: string | null; }): ChanceRequest | null { const ctx = params.workerContext; if (ctx && typeof ctx === "object") { diff --git a/src/skills/gm-tools.ts b/src/skills/gm-tools.ts new file mode 100644 index 0000000..c6d0725 --- /dev/null +++ b/src/skills/gm-tools.ts @@ -0,0 +1,119 @@ +/** + * 主世界层 harness 可用工具(游玩期)。 + * 仅挂载「本轮裁决缺了就没法写」的程序能力;转述/旁观/调度不在此列。 + */ +import type { ToolDefinition } from "../llm/client.js"; +import { + executeChanceBatch, + parseChanceBatchRequest, + type ChanceBatchResult, +} from "./chance-tools.js"; + +export const GM_CHANCE_TOOL_NAME = "chance" as const; + +export const GM_TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: "function", + function: { + name: GM_CHANCE_TOOL_NAME, + description: + "批量程序随机:掷骰、比点、抽签/加权抽取。需要多个随机时必须在 requests 数组中一次性全部提交;禁止口算或编造随机结果。", + parameters: { + type: "object", + properties: { + requests: { + type: "array", + description: + "本步所需的全部随机请求;每项必须有唯一 id,供后续 settlement 引用", + items: { + type: "object", + properties: { + id: { + type: "string", + description: "本批内唯一标识,如 attack-roll、loot-pick", + }, + op: { + type: "string", + enum: ["roll", "compare", "draw", "pick"], + }, + expression: { + type: "string", + description: "roll 时:如 1d20、2d6+3", + }, + left: { type: "number", description: "compare 时:左值" }, + right: { type: "number", description: "compare 时:右值" }, + mode: { + type: "string", + enum: ["gt", "gte", "lt", "lte", "eq"], + }, + pool: { + type: "array", + items: { type: "string" }, + description: "draw 时:候选池", + }, + count: { type: "number" }, + unique: { type: "boolean" }, + items: { + type: "array", + description: "pick 时:{ id, weight? }[]", + }, + reason: { type: "string" }, + }, + required: ["id", "op"], + }, + }, + }, + required: ["requests"], + additionalProperties: false, + }, + }, + }, +]; + +export function executeGmChanceTool(argumentsJson: string): ChanceBatchResult { + let args: unknown; + try { + args = JSON.parse(argumentsJson || "{}"); + } catch { + return { + schema: "chance.batch.v1", + ok: false, + results: [], + summary: "机遇失败:工具参数不是合法 JSON", + }; + } + const items = parseChanceBatchRequest(args); + if (!items?.length) { + return { + schema: "chance.batch.v1", + ok: false, + results: [], + summary: "机遇失败:requests 须为非空数组,且每项含 id 与 op", + }; + } + return executeChanceBatch(items); +} + +export function handleGmToolCall( + name: string, + argumentsJson: string, +): string | null { + if (name !== GM_CHANCE_TOOL_NAME) return null; + return JSON.stringify(executeGmChanceTool(argumentsJson), null, 2); +} + +/** 游玩拓扑 play_slots.chance:启用主世界层机遇工具 harness */ +export function gmChanceToolsEnabled( + playSlots: { chance?: boolean } | undefined, +): boolean { + if (!playSlots) return true; + return playSlots.chance !== false; +} + +export const GM_CHANCE_HARNESS_INSTRUCTION = ` + +## 机遇工具(游玩期) +需要真随机(掷骰/比点/抽签/加权抽取)时:调用工具 \`chance\`,在 \`requests\` 数组中**一次性**列出本步全部随机需求(每项必须有唯一 \`id\`)。 +禁止口算或编造随机结果。收到工具结果后,再在 settlement 的 \`resolved\` 等处引用对应 \`id\` 写出后果。 +无随机需求时:直接输出 settlement.v1 JSON,不要调用工具。 +`; diff --git a/src/worker/executor.ts b/src/worker/executor.ts index b328700..5519670 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -1,5 +1,5 @@ import type { Blackboard } from "../blackboard/blackboard.js"; -import type { LlmProvider, StreamCallbacks } from "../llm/client.js"; +import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js"; import { supportsContentStream } from "../llm/stream-complete.js"; import { loadWorkerSkillWithContext } from "../skills/loader.js"; import { filterInputsForRolePerspective } from "../skills/worker-llm.js"; @@ -21,6 +21,11 @@ import { resolveActivePreset } from "../preset/store.js"; import { assemblePlayWorkerMessages } from "../preset/play-frame.js"; import { worldInfoPackFromSegments } from "../preset/world-info-pack.js"; import { PRESENT_TAG } from "../skills/present-packet.js"; +import { + gmChanceToolsEnabled, + GM_CHANCE_HARNESS_INSTRUCTION, +} from "../skills/gm-tools.js"; +import { runGmHarness } from "./gm-harness.js"; import { mergeQuestionsPreferFragment, normalizeQuestions, @@ -38,6 +43,11 @@ import { serializeContextOrder, } from "../skills/context-order.js"; +export type WorkerStreamCallbacks = { + onThinkingDelta?: (delta: string) => void; + onOutputDelta?: (delta: string) => void; +}; + export type WorkerRunParams = { skillName: string; workerId: string; @@ -49,11 +59,6 @@ export type WorkerRunParams = { declared?: { worker: ParsedWorkerSkill; promptBody: string }; }; -export type WorkerStreamCallbacks = { - onThinkingDelta?: (delta: string) => void; - onOutputDelta?: (delta: string) => void; -}; - export type WorkerRunResult = { outputs: Record; summary: string; @@ -557,7 +562,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise m.role === "system"); + const rest = messages.filter((m) => m.role !== "system") as ChatMessage[]; + const harness = await runGmHarness({ + llm: params.llm, + system: systemMsg?.content ?? systemContent, + messages: rest, + stream: params.stream, + caller: `worker:${worker.id}`, + }); + return parseWorkerResponse(harness.content, worker.outputTags); + } + const result = await completeWorkerPreferStream( params.llm, messages, diff --git a/src/worker/gm-harness.ts b/src/worker/gm-harness.ts new file mode 100644 index 0000000..422028a --- /dev/null +++ b/src/worker/gm-harness.ts @@ -0,0 +1,84 @@ +/** + * 主世界层(world-simulator)游玩期 tool harness: + * 反复 completeWithTools 直到不再调用工具,最后一次正文视为裁决 JSON。 + */ +import type { ChatMessage, LlmProvider } from "../llm/client.js"; +import { + createLocalLlmDriver, + type DriverToolCall, +} from "../runtime/driver.js"; +import { + GM_CHANCE_TOOL_NAME, + GM_TOOL_DEFINITIONS, + handleGmToolCall, +} from "../skills/gm-tools.js"; + +export type GmHarnessStreamCallbacks = { + onThinkingDelta?: (delta: string) => void; + onOutputDelta?: (delta: string) => void; +}; + +export const DEFAULT_GM_HARNESS_MAX_ITERATIONS = 6; + +export type GmHarnessParams = { + llm: LlmProvider; + system: string; + messages: ChatMessage[]; + stream?: GmHarnessStreamCallbacks; + maxIterations?: number; + caller?: string; +}; + +export type GmHarnessResult = { + content: string; + iterations: number; +}; + +export async function runGmHarness( + params: GmHarnessParams, +): Promise { + const driver = createLocalLlmDriver(params.llm); + const label = "主世界层 harness"; + + const result = await driver.run({ + system: params.system, + messages: params.messages, + tools: GM_TOOL_DEFINITIONS, + caller: params.caller ?? "worker:world-simulator", + label, + maxIterations: params.maxIterations ?? DEFAULT_GM_HARNESS_MAX_ITERATIONS, + onThinkingDelta: (delta) => params.stream?.onThinkingDelta?.(delta), + onOutputDelta: (delta) => params.stream?.onOutputDelta?.(delta), + onThinkingDone: () => { + /* 思维链由 stream 增量展示 */ + }, + handleStep: (calls: DriverToolCall[]) => { + const results: Array<{ callId: string; content: string }> = []; + for (const call of calls) { + if (call.name !== GM_CHANCE_TOOL_NAME) { + results.push({ + callId: call.id, + content: JSON.stringify({ + ok: false, + error: `不允许的工具:${call.name}`, + }), + }); + continue; + } + const content = + handleGmToolCall(call.name, call.arguments) ?? + JSON.stringify({ ok: false, error: "未知工具" }); + results.push({ callId: call.id, content }); + } + return { kind: "continue", results }; + }, + }); + + if (result.stop.kind === "text") { + return { content: result.stop.content, iterations: result.iterations }; + } + + throw new Error( + `${label} 以边界 tool 结束(${result.stop.call.name}),主世界层仅允许 ${GM_CHANCE_TOOL_NAME}`, + ); +} diff --git a/tests/gm-tools.test.ts b/tests/gm-tools.test.ts new file mode 100644 index 0000000..d580b90 --- /dev/null +++ b/tests/gm-tools.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + executeChanceBatch, + parseChanceBatchRequest, +} from "../src/skills/chance-tools.js"; +import { + executeGmChanceTool, + gmChanceToolsEnabled, +} from "../src/skills/gm-tools.js"; + +describe("chance batch", () => { + it("parses requests array", () => { + const items = parseChanceBatchRequest({ + requests: [ + { id: "door", op: "roll", expression: "1d20" }, + { id: "win", op: "compare", left: 15, right: 12 }, + ], + }); + expect(items?.map((x) => x.id)).toEqual(["door", "win"]); + }); + + it("executeGmChanceTool returns batch schema", () => { + const parsed = executeGmChanceTool( + JSON.stringify({ + requests: [{ id: "x", op: "roll", expression: "1d6" }], + }), + ); + expect(parsed.schema).toBe("chance.batch.v1"); + expect(parsed.results[0]!.id).toBe("x"); + }); +}); + +describe("gmChanceToolsEnabled", () => { + it("defaults on when play_slots omitted", () => { + expect(gmChanceToolsEnabled(undefined)).toBe(true); + }); + + it("respects chance=false", () => { + expect(gmChanceToolsEnabled({ chance: false })).toBe(false); + }); + + it("enables when chance=true", () => { + expect(gmChanceToolsEnabled({ chance: true })).toBe(true); + }); +}); diff --git a/tests/play-slots.test.ts b/tests/play-slots.test.ts index 9707178..a317c9f 100644 --- a/tests/play-slots.test.ts +++ b/tests/play-slots.test.ts @@ -27,6 +27,7 @@ import { } from "../src/blackboard/table-cells.js"; import { executeChance, + executeChanceBatch, parseChanceRequest, } from "../src/skills/chance-tools.js"; import { synthesizeContextOrderFromWorkers } from "../src/skills/context-order.js"; @@ -185,15 +186,16 @@ describe("chance tools", () => { expect(result.detail.win).toBe(true); }); - it("draws from pool", () => { - const result = executeChance({ - op: "draw", - pool: ["甲", "乙", "丙"], - count: 2, - unique: true, - }); - expect(result.ok).toBe(true); - expect((result.detail.drawn as string[]).length).toBe(2); + it("batch executes multiple requests with ids", () => { + const batch = executeChanceBatch([ + { id: "a", op: "roll", expression: "1d6" }, + { id: "b", op: "compare", left: 10, right: 5, mode: "gte" }, + ]); + expect(batch.schema).toBe("chance.batch.v1"); + expect(batch.ok).toBe(true); + expect(batch.results).toHaveLength(2); + expect(batch.results[0]!.id).toBe("a"); + expect(batch.results[1]!.id).toBe("b"); }); });