Add gm harness with batch chance tools for play.
Main world layer can loop on the chance tool (requests array) until settlement JSON. Driver forwards output deltas; standalone chance worker accepts batch requests. play_slots.chance gates the harness. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@ role: core
|
|||||||
duty: >
|
duty: >
|
||||||
中立世界层:读用户输入、真值与 Progressive 投影,按规则推进并产出结构化裁决包
|
中立世界层:读用户输入、真值与 Progressive 投影,按规则推进并产出结构化裁决包
|
||||||
(settlement.v1 → 运行.本轮.裁决);可提议变量变更;默认不写文学正文。
|
(settlement.v1 → 运行.本轮.裁决);可提议变量变更;默认不写文学正文。
|
||||||
when: 每轮用户输入后
|
when: 每轮用户输入后首先上场
|
||||||
suggested_context:
|
suggested_context:
|
||||||
static:
|
static:
|
||||||
- 设计.worker集
|
- 设计.worker集
|
||||||
@@ -14,7 +14,6 @@ suggested_context:
|
|||||||
- 用户.最新输入
|
- 用户.最新输入
|
||||||
- 运行.事件流
|
- 运行.事件流
|
||||||
- 变量.当前
|
- 变量.当前
|
||||||
- 运行.本轮.旁观
|
|
||||||
- 上下文.角色态度
|
- 上下文.角色态度
|
||||||
- 大纲.当前章
|
- 大纲.当前章
|
||||||
suggested_outputs:
|
suggested_outputs:
|
||||||
@@ -22,11 +21,10 @@ suggested_outputs:
|
|||||||
- 运行.事件流
|
- 运行.事件流
|
||||||
- 运行.本轮.变量变更
|
- 运行.本轮.变量变更
|
||||||
prompt_excerpt: |
|
prompt_excerpt: |
|
||||||
你是中立主世界层(GM),不是文学作者。读真值、已投影上下文,以及本轮旁观维护包(若有)。
|
你是中立主世界层(GM),不是文学作者。读真值、已投影上下文与对话历史。
|
||||||
对用户行动作可复核裁决。「运行.本轮.裁决」必须是 JSON,schema 为 settlement.v1,至少尽量包含:
|
对用户行动作可复核裁决。「运行.本轮.裁决」必须是 JSON,schema 为 settlement.v1,至少尽量包含:
|
||||||
player_action、resolved[]、visible_now、npc_moves[]、variable_changes[]、do_not_say[]、tone_hint。
|
player_action、resolved[]、visible_now、npc_moves[]、variable_changes[]、do_not_say[]、tone_hint。
|
||||||
variable_changes 由 Runtime 合并进变量.当前——请写清 key 与 to/delta。
|
variable_changes 由 Runtime 合并进变量.当前——请写清 key 与 to/delta。
|
||||||
叙事质量 intentionally 低——用户正文交给 narrator。
|
叙事质量 intentionally 低——用户正文交给 narrator。
|
||||||
需要真随机(骰子/抽签/比点)时:不要自己编结果;依赖已启用的机遇裁定(chance)
|
需要真随机时:调用 chance 工具,在 requests 数组中一次性提交全部掷骰/抽取(每项带 id);禁止口算。
|
||||||
写出的「运行.本轮.机遇」,或由编排器先 run_worker chance。
|
|
||||||
不要把未投影的分档长文整表塞进输出;只写本轮可见后果。
|
不要把未投影的分档长文整表塞进输出;只写本轮可见后果。
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type DriverRunInput = {
|
|||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
onThinkingDelta?: (delta: string) => void;
|
onThinkingDelta?: (delta: string) => void;
|
||||||
onThinkingDone?: (text: string) => void;
|
onThinkingDone?: (text: string) => void;
|
||||||
|
onOutputDelta?: (delta: string) => void;
|
||||||
handleStep: (
|
handleStep: (
|
||||||
calls: DriverToolCall[],
|
calls: DriverToolCall[],
|
||||||
) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
||||||
@@ -109,6 +110,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
|
|||||||
];
|
];
|
||||||
const streamCallbacks: StreamCallbacks = {
|
const streamCallbacks: StreamCallbacks = {
|
||||||
onReasoningDelta: (delta) => input.onThinkingDelta?.(delta),
|
onReasoningDelta: (delta) => input.onThinkingDelta?.(delta),
|
||||||
|
onContentDelta: (delta) => input.onOutputDelta?.(delta),
|
||||||
};
|
};
|
||||||
const label = input.label ?? "driver";
|
const label = input.label ?? "driver";
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ import {
|
|||||||
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||||
import {
|
import {
|
||||||
executeChance,
|
executeChance,
|
||||||
|
executeChanceBatch,
|
||||||
|
resolveChanceBatchRequest,
|
||||||
resolveChanceRequest,
|
resolveChanceRequest,
|
||||||
} from "../skills/chance-tools.js";
|
} from "../skills/chance-tools.js";
|
||||||
import {
|
import {
|
||||||
@@ -1412,21 +1414,31 @@ export class PhaseRuntime {
|
|||||||
).session;
|
).session;
|
||||||
|
|
||||||
const boardRaw = this.blackboard.getContentByTag("运行.机会请求");
|
const boardRaw = this.blackboard.getContentByTag("运行.机会请求");
|
||||||
const request = resolveChanceRequest({
|
const batch = resolveChanceBatchRequest({
|
||||||
workerContext: effect.workerContext ?? null,
|
workerContext: effect.workerContext ?? null,
|
||||||
blackboardRequestJson: boardRaw,
|
blackboardRequestJson: boardRaw,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = request
|
const result = batch?.length
|
||||||
? executeChance(request)
|
? batch.length === 1
|
||||||
: {
|
? executeChance(batch[0]!)
|
||||||
schema: "chance.v1" as const,
|
: executeChanceBatch(batch)
|
||||||
op: "roll" as const,
|
: (() => {
|
||||||
ok: false,
|
const single = resolveChanceRequest({
|
||||||
summary: "机遇失败:缺少请求(workerContext.chance 或 运行.机会请求)",
|
workerContext: effect.workerContext ?? null,
|
||||||
detail: {},
|
blackboardRequestJson: boardRaw,
|
||||||
error: "missing_request",
|
});
|
||||||
};
|
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 content = JSON.stringify(result, null, 2);
|
||||||
const written = this.writeWorkerTagContent(
|
const written = this.writeWorkerTagContent(
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ export type ChanceResult = {
|
|||||||
error?: string;
|
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*$/;
|
const DICE_RE = /^\s*(\d*)\s*[dD]\s*(\d+)\s*([+-]\s*\d+)?\s*$/;
|
||||||
|
|
||||||
function randInt(min: number, max: number): number {
|
function randInt(min: number, max: number): number {
|
||||||
@@ -305,9 +318,100 @@ function executePick(req: ChancePickRequest, reason?: string): ChanceResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从黑板正文或 workerContext 解析请求 */
|
/** 从黑板正文或 workerContext 解析请求 */
|
||||||
|
export function parseChanceBatchRequest(
|
||||||
|
raw: unknown,
|
||||||
|
): ChanceBatchRequestItem[] | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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: {
|
export function resolveChanceRequest(params: {
|
||||||
workerContext?: Record<string, unknown> | null;
|
workerContext?: Record<string, unknown> | null;
|
||||||
blackboardRequestJson?: string | 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<string, unknown> | 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<string, unknown>).id === "string"
|
||||||
|
? String((nested as Record<string, unknown>).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<string, unknown> | null;
|
||||||
|
blackboardRequestJson?: string | null;
|
||||||
}): ChanceRequest | null {
|
}): ChanceRequest | null {
|
||||||
const ctx = params.workerContext;
|
const ctx = params.workerContext;
|
||||||
if (ctx && typeof ctx === "object") {
|
if (ctx && typeof ctx === "object") {
|
||||||
|
|||||||
@@ -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,不要调用工具。
|
||||||
|
`;
|
||||||
+37
-7
@@ -1,5 +1,5 @@
|
|||||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
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 { supportsContentStream } from "../llm/stream-complete.js";
|
||||||
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
||||||
import { filterInputsForRolePerspective } from "../skills/worker-llm.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 { assemblePlayWorkerMessages } from "../preset/play-frame.js";
|
||||||
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
|
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
|
||||||
import { PRESENT_TAG } from "../skills/present-packet.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 {
|
import {
|
||||||
mergeQuestionsPreferFragment,
|
mergeQuestionsPreferFragment,
|
||||||
normalizeQuestions,
|
normalizeQuestions,
|
||||||
@@ -38,6 +43,11 @@ import {
|
|||||||
serializeContextOrder,
|
serializeContextOrder,
|
||||||
} from "../skills/context-order.js";
|
} from "../skills/context-order.js";
|
||||||
|
|
||||||
|
export type WorkerStreamCallbacks = {
|
||||||
|
onThinkingDelta?: (delta: string) => void;
|
||||||
|
onOutputDelta?: (delta: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkerRunParams = {
|
export type WorkerRunParams = {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
workerId: string;
|
workerId: string;
|
||||||
@@ -49,11 +59,6 @@ export type WorkerRunParams = {
|
|||||||
declared?: { worker: ParsedWorkerSkill; promptBody: string };
|
declared?: { worker: ParsedWorkerSkill; promptBody: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkerStreamCallbacks = {
|
|
||||||
onThinkingDelta?: (delta: string) => void;
|
|
||||||
onOutputDelta?: (delta: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkerRunResult = {
|
export type WorkerRunResult = {
|
||||||
outputs: Record<string, string>;
|
outputs: Record<string, string>;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -557,7 +562,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
|
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemContent = promptBody + WORKER_OUTPUT_INSTRUCTION;
|
const useGmHarness =
|
||||||
|
isPlayLayerActive(params.slots) &&
|
||||||
|
worker.id === "world-simulator" &&
|
||||||
|
gmChanceToolsEnabled(
|
||||||
|
parseWorkerSetYaml(
|
||||||
|
params.blackboard.getContentByTag("设计.worker集") ?? "",
|
||||||
|
)?.play_slots,
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemContent =
|
||||||
|
promptBody +
|
||||||
|
WORKER_OUTPUT_INSTRUCTION +
|
||||||
|
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
||||||
const playPreset = isPlayLayerActive(params.slots)
|
const playPreset = isPlayLayerActive(params.slots)
|
||||||
? resolveActivePreset(loadAppSettings().activePresetId)
|
? resolveActivePreset(loadAppSettings().activePresetId)
|
||||||
: null;
|
: null;
|
||||||
@@ -589,6 +606,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (useGmHarness) {
|
||||||
|
const systemMsg = messages.find((m) => 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(
|
const result = await completeWorkerPreferStream(
|
||||||
params.llm,
|
params.llm,
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
@@ -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<GmHarnessResult> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "../src/blackboard/table-cells.js";
|
} from "../src/blackboard/table-cells.js";
|
||||||
import {
|
import {
|
||||||
executeChance,
|
executeChance,
|
||||||
|
executeChanceBatch,
|
||||||
parseChanceRequest,
|
parseChanceRequest,
|
||||||
} from "../src/skills/chance-tools.js";
|
} from "../src/skills/chance-tools.js";
|
||||||
import { synthesizeContextOrderFromWorkers } from "../src/skills/context-order.js";
|
import { synthesizeContextOrderFromWorkers } from "../src/skills/context-order.js";
|
||||||
@@ -185,15 +186,16 @@ describe("chance tools", () => {
|
|||||||
expect(result.detail.win).toBe(true);
|
expect(result.detail.win).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws from pool", () => {
|
it("batch executes multiple requests with ids", () => {
|
||||||
const result = executeChance({
|
const batch = executeChanceBatch([
|
||||||
op: "draw",
|
{ id: "a", op: "roll", expression: "1d6" },
|
||||||
pool: ["甲", "乙", "丙"],
|
{ id: "b", op: "compare", left: 10, right: 5, mode: "gte" },
|
||||||
count: 2,
|
]);
|
||||||
unique: true,
|
expect(batch.schema).toBe("chance.batch.v1");
|
||||||
});
|
expect(batch.ok).toBe(true);
|
||||||
expect(result.ok).toBe(true);
|
expect(batch.results).toHaveLength(2);
|
||||||
expect((result.detail.drawn as string[]).length).toBe(2);
|
expect(batch.results[0]!.id).toBe("a");
|
||||||
|
expect(batch.results[1]!.id).toBe("b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user