Files
writing-agent/tests/preset-tool-policy.test.ts
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

301 lines
8.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ToolDefinition } from "../src/llm/client.js";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import {
clearToolPlanCache,
decorateTools,
normalizeToolCalls,
resolveToolPlan,
} from "../src/preset/tool-policy.js";
const submitTool: ToolDefinition = {
type: "function",
function: {
name: "submit",
description: "提交产物",
parameters: {
type: "object",
properties: {
content: { type: "string" },
outputTags: { type: "array", items: { type: "string" } },
},
required: ["content", "outputTags"],
},
},
};
const readTool: ToolDefinition = {
type: "function",
function: {
name: "read_blackboard",
description: "读正文",
parameters: {
type: "object",
properties: { tags: { type: "array" } },
required: ["tags"],
},
},
};
function presetWithBindings() {
return importSillyTavernPreset(
{
prompts: [
{ identifier: "main", name: "main", role: "system", content: "主线" },
{
identifier: "bound",
name: "防截断2.0",
role: "system",
content: "所有输出必须调用 game_content 工具,放在 content 参数中",
},
],
prompt_order: [
{
character_id: 100001,
order: [
{ identifier: "main", enabled: true },
{ identifier: "bound", enabled: true },
],
},
],
extensions: {
SPreset: {
ToolBindings: {
bound: {
enabled: true,
form: {
name: "game_content",
description: "# 你的全部输出应当在此工具内。",
parameters: [{ name: "content", type: "string", required: true }],
},
},
},
},
},
},
{ name: "Reborn-like" },
).preset;
}
function presetWithPlaceholders() {
return importSillyTavernPreset(
{
prompts: [
{
identifier: "guide",
name: "【ToolCall引导】工具说明",
role: "system",
content: "[Interface]\nactive_interface = %TOOL_NAME%\nstream_target = display_stream",
},
],
prompt_order: [
{
character_id: 100001,
order: [{ identifier: "guide", enabled: true }],
},
],
},
{ name: "linux-like" },
).preset;
}
describe("resolveToolPlan", () => {
it("prefers SPreset.ToolBindings for the target tool and payload field", () => {
const plan = resolveToolPlan(presetWithBindings());
expect(plan.present).toBe(true);
expect(plan.targetName).toBe("game_content");
expect(plan.payloadFields).toEqual(["content"]);
// 描述只取作者写在 ToolBindings 里的那句,不抄条目正文
expect(plan.description).toBe("# 你的全部输出应当在此工具内。");
});
it("falls back to placeholders when no binding exists", () => {
const plan = resolveToolPlan(presetWithPlaceholders());
expect(plan.present).toBe(true);
expect(plan.targetName).toBeNull();
expect(plan.payloadFields).toContain("display_stream");
});
it("is quiet when the preset has no tool protocol", () => {
const preset = importSillyTavernPreset(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "只写正文" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
},
{ name: "plain" },
).preset;
expect(resolveToolPlan(preset).present).toBe(false);
});
});
describe("tool plan 缓存", () => {
it("returns a stale plan until the cache is cleared", () => {
const preset = presetWithPlaceholders();
expect(resolveToolPlan(preset).present).toBe(true);
// 模拟用户把所有条目关掉后保存(id 与 importedAt 不变)
const edited = {
...preset,
promptOrder: preset.promptOrder.map((o) => ({ ...o, enabled: false })),
};
expect(resolveToolPlan(edited).present).toBe(true);
clearToolPlanCache(edited.id);
expect(resolveToolPlan(edited).present).toBe(false);
});
});
describe("decorateTools", () => {
it("leaves tools alone when the preset has no tool protocol", () => {
const preset = importSillyTavernPreset(
{
prompts: [{ identifier: "main", name: "main", role: "system", content: "写" }],
prompt_order: [
{ character_id: 100001, order: [{ identifier: "main", enabled: true }] },
],
},
{ name: "plain" },
).preset;
const out = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(preset),
});
expect(out.tools).toEqual([submitTool]);
expect(out.aliases).toEqual([]);
});
it("app mode never changes the tool table", () => {
const out = decorateTools({
mode: "app",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools).toEqual([submitTool]);
});
it("adapt adds the declared tool and aliases it onto the host tool", () => {
const out = decorateTools({
mode: "adapt",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual([
"read_blackboard",
"submit",
"game_content",
]);
expect(out.aliases).toEqual([
{ from: "game_content", to: "submit", payloadFields: ["content"] },
]);
});
it("adapt folds placeholder payload fields into the host schema", () => {
const out = decorateTools({
mode: "adapt",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithPlaceholders()),
});
expect(out.tools).toHaveLength(2);
const submit = out.tools.find((t) => t.function.name === "submit")!;
const properties = (submit.function.parameters as {
properties: Record<string, unknown>;
}).properties;
expect(Object.keys(properties)).toContain("display_stream");
expect(submit.function.description).toContain("display_stream");
expect(out.aliases).toEqual([]);
});
it("adapt creates a payload tool when there is no host tool", () => {
const out = decorateTools({
mode: "adapt",
tools: [],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
});
it("preset mode sends only the preset tool", () => {
const out = decorateTools({
mode: "preset",
tools: [readTool, submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
expect(out.tools.map((t) => t.function.name)).toEqual(["game_content"]);
});
});
describe("normalizeToolCalls", () => {
it("rewrites an aliased call onto the host tool and its primary field", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const calls = normalizeToolCalls(
[
{
id: "1",
name: "game_content",
arguments: JSON.stringify({ content: "第一章", extra: 1 }),
},
],
decoration,
decoration.tools,
);
expect(calls[0]?.name).toBe("submit");
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
// content 已经落到宿主工具的主字段上,不再重复
expect(args.content).toBe("第一章");
expect(args.extra).toBe(1);
});
it("leaves unrelated calls untouched", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const call = { id: "2", name: "submit", arguments: '{"content":"x"}' };
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
call,
]);
});
it("renames an aliased call even without a payload", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithBindings()),
});
const call = { id: "3", name: "game_content", arguments: "{}" };
expect(normalizeToolCalls([call], decoration, decoration.tools)).toEqual([
{ ...call, name: "submit" },
]);
});
it("folds a payload field onto the host primary field", () => {
const decoration = decorateTools({
mode: "adapt",
tools: [submitTool],
plan: resolveToolPlan(presetWithPlaceholders()),
});
const calls = normalizeToolCalls(
[
{
id: "4",
name: "submit",
arguments: JSON.stringify({ display_stream: "正文" }),
},
],
decoration,
decoration.tools,
);
const args = JSON.parse(calls[0]!.arguments) as Record<string, unknown>;
expect(args.content).toBe("正文");
expect(args).not.toHaveProperty("display_stream");
});
});