预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user