@y9931666/dsh-plugin-crossfire 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -7
- package/lib/index.js +643 -52
- package/lib/types/engine.d.ts +22 -4
- package/lib/types/plan.d.ts +111 -0
- package/lib/types/prompt.d.ts +1 -1
- package/lib/types/redteam.d.ts +12 -4
- package/lib/types/tools.d.ts +2 -1
- package/lib/types/types.d.ts +28 -0
- package/lib/types/version.d.ts +3 -3
- package/package.json +40 -37
- package/release-notes/v0.2.0.md +44 -0
package/lib/index.js
CHANGED
|
@@ -92,16 +92,6 @@ function beginPass(record) {
|
|
|
92
92
|
//#endregion
|
|
93
93
|
//#region src/types.ts
|
|
94
94
|
/**
|
|
95
|
-
* Crossfire(针锋)的领域类型。
|
|
96
|
-
*
|
|
97
|
-
* 设计边界(与圆桌会议刻意不同,务必守住):
|
|
98
|
-
* - 没有 nodes / edges / topology —— 专家是行,不是图。
|
|
99
|
-
* - 没有 charter 的多模式 —— 只有一种角色:红队。
|
|
100
|
-
* - 没有 kb / skill / proxy-thinking 字段 —— 这个插件不读知识库、不传 skill。
|
|
101
|
-
*
|
|
102
|
-
* @module dsh-plugin-crossfire/types
|
|
103
|
-
*/
|
|
104
|
-
/**
|
|
105
95
|
* 固定维度池。
|
|
106
96
|
*
|
|
107
97
|
* 为什么必须固定:抽样 5 份真实圆桌 `review.json`,`dimension` 实值是
|
|
@@ -260,12 +250,50 @@ function sanitizeExpert(value) {
|
|
|
260
250
|
if (model !== void 0) expert.model = model;
|
|
261
251
|
const role = asText(raw.role, 2e3);
|
|
262
252
|
if (role !== void 0) expert.role = role;
|
|
253
|
+
const prompt = asText(raw.prompt, 2e3);
|
|
254
|
+
if (prompt !== void 0) expert.prompt = prompt;
|
|
263
255
|
const persona = asText(raw.persona, 32e3);
|
|
264
256
|
if (persona !== void 0) expert.persona = persona;
|
|
265
257
|
const note = asText(raw.note, 500);
|
|
266
258
|
if (note !== void 0) expert.note = note;
|
|
267
259
|
return expert;
|
|
268
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* 净化确认卡落盘的阵容(`CrossfireRecord.expertRoster`)。
|
|
263
|
+
*
|
|
264
|
+
* `key` 是唯一必填项(与 `planExperts` 的匹配语义一致);其余字段只保留非空值
|
|
265
|
+
* —— 空串与缺席在 persona/路由两处的语义完全相同("没写"),落空串只会让记录
|
|
266
|
+
* 里多一个看起来有值的字段。
|
|
267
|
+
*
|
|
268
|
+
* 整体为空时返回 undefined(而不是 `[]`):缺席与空数组在本插件里没有行为差异,
|
|
269
|
+
* 但不写这个字段能让"没走确认卡"的老记录保持与从前逐字相同的形状。
|
|
270
|
+
*/
|
|
271
|
+
function sanitizeRoster(value) {
|
|
272
|
+
if (!Array.isArray(value)) return void 0;
|
|
273
|
+
const entries = [];
|
|
274
|
+
for (const item of value) {
|
|
275
|
+
if (item === null || typeof item !== "object") continue;
|
|
276
|
+
const raw = item;
|
|
277
|
+
const key = asText(raw.key, 64);
|
|
278
|
+
if (key === void 0) continue;
|
|
279
|
+
const entry = { key };
|
|
280
|
+
const dimension = asText(raw.dimension, 32);
|
|
281
|
+
if (dimension !== void 0) entry.dimension = dimension;
|
|
282
|
+
const role = asText(raw.role, 2e3);
|
|
283
|
+
if (role !== void 0) entry.role = role;
|
|
284
|
+
const provider = asText(raw.provider, 64);
|
|
285
|
+
if (provider !== void 0) entry.provider = provider;
|
|
286
|
+
const model = asText(raw.model, 128);
|
|
287
|
+
if (model !== void 0) entry.model = model;
|
|
288
|
+
const prompt = asText(raw.prompt, 2e3);
|
|
289
|
+
if (prompt !== void 0) entry.prompt = prompt;
|
|
290
|
+
const name$1 = asText(raw.name, 120);
|
|
291
|
+
if (name$1 !== void 0) entry.name = name$1;
|
|
292
|
+
entries.push(entry);
|
|
293
|
+
if (entries.length >= 64) break;
|
|
294
|
+
}
|
|
295
|
+
return entries.length > 0 ? entries : void 0;
|
|
296
|
+
}
|
|
269
297
|
function sanitizeStance(value, now) {
|
|
270
298
|
const raw = value !== null && typeof value === "object" ? value : {};
|
|
271
299
|
const text$1 = asText(raw.text, 2e3) ?? "";
|
|
@@ -379,6 +407,8 @@ function normalizeRecord(raw, reviewId) {
|
|
|
379
407
|
};
|
|
380
408
|
const sourceMeeting = asText(source.sourceMeeting, 128);
|
|
381
409
|
if (sourceMeeting !== void 0) record.sourceMeeting = sourceMeeting;
|
|
410
|
+
const expertRoster = sanitizeRoster(source.expertRoster);
|
|
411
|
+
if (expertRoster !== void 0) record.expertRoster = expertRoster;
|
|
382
412
|
const ownerSessionId = asText(source.ownerSessionId, 128);
|
|
383
413
|
if (ownerSessionId !== void 0) record.ownerSessionId = ownerSessionId;
|
|
384
414
|
const settledAt = asCount(source.settledAt);
|
|
@@ -823,15 +853,16 @@ function sendJsonConditional(requestHeaders, res, payload, status = 200) {
|
|
|
823
853
|
*
|
|
824
854
|
* @module dsh-plugin-crossfire/prompt
|
|
825
855
|
*/
|
|
826
|
-
/**
|
|
856
|
+
/** 八步编排(预检 + 状态机七步)与那一条不可越界的规则。 */
|
|
827
857
|
const USAGE_STEPS = [
|
|
828
|
-
"1. `
|
|
829
|
-
"2. `
|
|
830
|
-
"3. `
|
|
831
|
-
"4. `
|
|
832
|
-
"5.
|
|
833
|
-
"6.
|
|
834
|
-
"7. `
|
|
858
|
+
"1. `crossfire_plan_review` —— **先弹设置确认卡**:把评审标题 / 正文 / 来源 / 强度(每维专家数、复审上限、轮数、Token)/ 维度 / 立场 / 红队阵容逐行摊成两张表,等人点「确定开始」。点「仍需修改」就按用户的话改草案、带 `changes[]` 再弹一次;**确认前不要 open**。",
|
|
859
|
+
"2. `crossfire_open` —— 把待验证对象摆上台(标题 + 正文 + 维度 + 强度,可选立场)。把确认卡返回的 `experts` **原样**传入(它会落盘成这场评审的阵容)。状态 `draft`。",
|
|
860
|
+
"3. `crossfire_stance`(可选但强烈建议)—— 写入\"用户的立场与理由\"。用户自己写最好;他没写就**从对话提炼**并传 `distilled=true`,但**绝不虚构**他没表达过的立场(虚构的立场等于给红队一个假靶子)。有了立场,每条红队缺陷都必须回应其中一条理由,并标注是否被打穿。用户随时可以在卡片上改。",
|
|
861
|
+
"4. `crossfire_attack` —— 按维度拉起只读红队专家(名单优先用确认卡落盘的阵容)。状态 `attacking`。",
|
|
862
|
+
"5. `crossfire_collect` —— 把专家发言拆成逐条可裁决的缺陷(自动过滤过程叙述、归一化维度、去掉用户上轮驳回过的重复项)。状态 `judging`。",
|
|
863
|
+
"6. **等人在卡片上逐条表态**。这一步没有工具可代劳。",
|
|
864
|
+
"7. `crossfire_finish` —— 定稿,把已认定缺陷固化为 known-flaws 清单并写入历史。状态 `settled`。",
|
|
865
|
+
"8. `crossfire_recheck` —— 复审:只核对上一轮已认定的缺陷是否修复,**不许开新战线**;`crossfire_export` 导出 Markdown。"
|
|
835
866
|
];
|
|
836
867
|
/**
|
|
837
868
|
* 当前版本的用法段落。
|
|
@@ -849,7 +880,7 @@ function usageSectionText() {
|
|
|
849
880
|
"3. **红队专家只读。** 它们的工具被限制为只读检索(read / read_image / glob / grep)加一个 `crossfire_report`:只挑错,不动工作区。",
|
|
850
881
|
"4. **不许把用户偏好喂给红队。** 历史表态记录只用于去重与复审递进,绝不用来塑造专家对\"用户喜欢什么\"的预期。",
|
|
851
882
|
"",
|
|
852
|
-
"###
|
|
883
|
+
"### 编排(八步:预检一步 + 状态机七步,每步可单独重试)",
|
|
853
884
|
"",
|
|
854
885
|
...USAGE_STEPS,
|
|
855
886
|
"",
|
|
@@ -1637,11 +1668,13 @@ async function splitUtterance(llm, config, expertKey, content, allowed, stance)
|
|
|
1637
1668
|
* `record.dimensions` 的顺序截断,所以**用户排在前面的维度优先** —— 这是可预期的,
|
|
1638
1669
|
* 而不是随机丢人。
|
|
1639
1670
|
*
|
|
1640
|
-
*
|
|
1641
|
-
* 1. `roster` 里 `
|
|
1642
|
-
*
|
|
1671
|
+
* 人选来源三条,按优先级:
|
|
1672
|
+
* 1. `roster` 里 `dimension` 等于该维度的那一条(确认卡落盘的阵容就是这种形状:
|
|
1673
|
+
* 用户确认的是"安全这一行",而不是某个恰好叫安全的 key);
|
|
1674
|
+
* 2. 否则 `key` 等于维度名的那一条(精确指定,最符合直觉);
|
|
1675
|
+
* 3. 否则按**顺序**取 roster 的第 i 条(i = 维度序号)—— 用户不必把 key 改名成
|
|
1643
1676
|
* 维度名也能用上一份现成的阵容;
|
|
1644
|
-
*
|
|
1677
|
+
* 4. 都取不到时只写维度与状态(专家继承主持人路由、无补充定位),与从前完全一致。
|
|
1645
1678
|
*
|
|
1646
1679
|
* @param roster - 可选人选来源;缺席或为空时行为与从前逐字一致。
|
|
1647
1680
|
*/
|
|
@@ -1651,7 +1684,7 @@ function planExperts(record, maxExperts, roster = []) {
|
|
|
1651
1684
|
let dimensionIndex = 0;
|
|
1652
1685
|
for (const dimension of record.dimensions) {
|
|
1653
1686
|
const count = Math.max(1, spec.expertsPerDimension);
|
|
1654
|
-
const matched = roster.find((entry) => entry.key === dimension) ?? roster[dimensionIndex];
|
|
1687
|
+
const matched = roster.find((entry) => entry.dimension !== void 0 && entry.dimension === dimension) ?? roster.find((entry) => entry.key === dimension) ?? roster[dimensionIndex];
|
|
1655
1688
|
dimensionIndex += 1;
|
|
1656
1689
|
for (let index = 0; index < count; index += 1) {
|
|
1657
1690
|
const expert = {
|
|
@@ -1664,6 +1697,7 @@ function planExperts(record, maxExperts, roster = []) {
|
|
|
1664
1697
|
if (matched.role !== void 0 && matched.role.trim() !== "") expert.role = matched.role.trim();
|
|
1665
1698
|
if (matched.provider !== void 0 && matched.provider.trim() !== "") expert.provider = matched.provider.trim();
|
|
1666
1699
|
if (matched.model !== void 0 && matched.model.trim() !== "") expert.model = matched.model.trim();
|
|
1700
|
+
if (matched.prompt !== void 0 && matched.prompt.trim() !== "") expert.prompt = matched.prompt.trim();
|
|
1667
1701
|
}
|
|
1668
1702
|
experts.push(expert);
|
|
1669
1703
|
}
|
|
@@ -1887,10 +1921,10 @@ const EXPERT_TOOL_ALLOW = [
|
|
|
1887
1921
|
/**
|
|
1888
1922
|
* 红队专家禁止使用的工具(黑名单)。
|
|
1889
1923
|
*
|
|
1890
|
-
* 这里列的是全部**主持人专属**的 `crossfire_*`
|
|
1891
|
-
*
|
|
1892
|
-
* `crossfire_collect`/`crossfire_finish
|
|
1893
|
-
* 当场失效。
|
|
1924
|
+
* 这里列的是全部**主持人专属**的 `crossfire_*` 工具:启动前确认、开评审、发起
|
|
1925
|
+
* 攻击、收集缺陷、定稿、复审、导出、写入立场。这些都是主持人的编排权,专家一旦
|
|
1926
|
+
* 能调 `crossfire_plan_review`/`crossfire_collect`/`crossfire_finish`,就等于自己
|
|
1927
|
+
* 给自己结案,"表态由人做"当场失效。
|
|
1894
1928
|
*
|
|
1895
1929
|
* 为什么白名单之外还要再来一份黑名单:两者是**与**关系(必须同时在白名单里
|
|
1896
1930
|
* 且不在黑名单里才可见)。将来有人为了加一个只读工具而改动
|
|
@@ -1900,6 +1934,7 @@ const EXPERT_TOOL_ALLOW = [
|
|
|
1900
1934
|
* **注意**:`crossfire_report` 是**专家自己的**发言工具,绝不能列进本表。
|
|
1901
1935
|
*/
|
|
1902
1936
|
const EXPERT_TOOL_DENY = [
|
|
1937
|
+
"crossfire_plan_review",
|
|
1903
1938
|
"crossfire_open",
|
|
1904
1939
|
"crossfire_attack",
|
|
1905
1940
|
"crossfire_collect",
|
|
@@ -1958,7 +1993,7 @@ function reportEnvelopeSpec(maxOpinions) {
|
|
|
1958
1993
|
].join("\n");
|
|
1959
1994
|
}
|
|
1960
1995
|
/** 非空判定:兼容 `undefined` 与纯空白("没写立场"比"写了几个空格"更常见)。 */
|
|
1961
|
-
function hasText(value) {
|
|
1996
|
+
function hasText$1(value) {
|
|
1962
1997
|
return value !== void 0 && value.trim() !== "";
|
|
1963
1998
|
}
|
|
1964
1999
|
/** 截断到上限,并明确标注被截断(截断不能静默发生)。 */
|
|
@@ -2023,7 +2058,7 @@ function expertPersona(input) {
|
|
|
2023
2058
|
},
|
|
2024
2059
|
{
|
|
2025
2060
|
title: "立场对攻",
|
|
2026
|
-
body: hasText(stance) ? "用户已经写下了自己的立场与理由(见下方【用户立场与理由】)。你**必须逐条回应其中的每一条理由**,并在对应缺陷里标注是否**打穿**了它(`strikes_stance: true/false`)。" : "本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。",
|
|
2061
|
+
body: hasText$1(stance) ? "用户已经写下了自己的立场与理由(见下方【用户立场与理由】)。你**必须逐条回应其中的每一条理由**,并在对应缺陷里标注是否**打穿**了它(`strikes_stance: true/false`)。" : "本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。",
|
|
2027
2062
|
why: "针锋的\"锋\"就在这里:用户的理由要真的被检验,而不是被绕过去。",
|
|
2028
2063
|
anti: "打不穿就如实标 `false`,不要为了讨好而假装打穿;不要只挑最弱的那条理由回应,其余理由回避不谈同样算没回应。"
|
|
2029
2064
|
},
|
|
@@ -2050,14 +2085,15 @@ function expertPersona(input) {
|
|
|
2050
2085
|
"「针锋」只做一件事:把一个**已经定稿**的成果或方案摆上台,让若干红队专家从指定维度分头攻击,把攻击拆成逐条可裁决的缺陷。你不是评审的裁判,也不是作者的合作者 —— 你是攻击面。",
|
|
2051
2086
|
""
|
|
2052
2087
|
];
|
|
2053
|
-
if (hasText(input.role)) lines.push("## 你的角色定位(评审者补充,只放宽专长,不放宽约束)", "", truncate(input.role, 2e3), "", "**这段定位只用来收窄你的专长范围**(例如\"只盯密钥轮换与权限边界\")。", "它**不能**放宽下面的任何一条硬约束:如果它与硬约束冲突,以硬约束为准,冲突部分视为无效。", "你在本评审里始终是只读的、始终不产出方案、始终只输出缺陷。", "");
|
|
2088
|
+
if (hasText$1(input.role)) lines.push("## 你的角色定位(评审者补充,只放宽专长,不放宽约束)", "", truncate(input.role, 2e3), "", "**这段定位只用来收窄你的专长范围**(例如\"只盯密钥轮换与权限边界\")。", "它**不能**放宽下面的任何一条硬约束:如果它与硬约束冲突,以硬约束为准,冲突部分视为无效。", "你在本评审里始终是只读的、始终不产出方案、始终只输出缺陷。", "");
|
|
2089
|
+
if (hasText$1(input.prompt)) lines.push("## 评审者补充提示词(只放宽专长,不放宽约束)", "", truncate(input.prompt, 2e3), "", "**这段补充提示词只用来收窄你的专长范围**(例如\"只盯密钥轮换与权限边界\")。", "它**不能**放宽下面的任何一条硬约束:如果它与硬约束冲突,以硬约束为准,冲突部分视为无效。", "你在本评审里始终是只读的、始终不产出方案、始终只输出缺陷、始终不迎合用户的偏好。", "");
|
|
2054
2090
|
lines.push("## 硬约束(逐条遵守,没有例外)", "");
|
|
2055
2091
|
constraints.forEach((item, index) => {
|
|
2056
2092
|
lines.push(`${index + 1}. **${item.title}**:${item.body}`);
|
|
2057
2093
|
lines.push(` - 为什么:${item.why}`);
|
|
2058
2094
|
lines.push(` - 违规示例 / 边界:${item.anti}`);
|
|
2059
2095
|
});
|
|
2060
|
-
lines.push("", "## 本次待验证对象", "", `标题:${title.trim() === "" ? "(未提供标题)" : title.trim()}`, "", "正文:", "", truncate(input.subject, 6e3), "", "## 用户立场与理由", "", hasText(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按上面的维度攻击。)", "", reportEnvelopeSpec(maxOpinions), "", "## 结束方式", "", "把缺陷通过 `crossfire_report` 提交后即可结束本轮。", "不要提交\"我已完成审查\"这类过程叙述 —— 只有包含「观点 N(…):」标记的发言才会进入裁决列表,", "过程叙述会被丢弃,等于本轮什么都没产出。");
|
|
2096
|
+
lines.push("", "## 本次待验证对象", "", `标题:${title.trim() === "" ? "(未提供标题)" : title.trim()}`, "", "正文:", "", truncate(input.subject, 6e3), "", "## 用户立场与理由", "", hasText$1(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按上面的维度攻击。)", "", reportEnvelopeSpec(maxOpinions), "", "## 结束方式", "", "把缺陷通过 `crossfire_report` 提交后即可结束本轮。", "不要提交\"我已完成审查\"这类过程叙述 —— 只有包含「观点 N(…):」标记的发言才会进入裁决列表,", "过程叙述会被丢弃,等于本轮什么都没产出。");
|
|
2061
2097
|
return lines.join("\n");
|
|
2062
2098
|
}
|
|
2063
2099
|
/**
|
|
@@ -2082,7 +2118,7 @@ function expertWelcome(input) {
|
|
|
2082
2118
|
truncate(subject, 6e3),
|
|
2083
2119
|
"",
|
|
2084
2120
|
"输入 2 —— 用户立场与理由(每条都必须被逐条回应并标注是否打穿):",
|
|
2085
|
-
hasText(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。)",
|
|
2121
|
+
hasText$1(stance) ? truncate(stance, 4e3) : "(本次没有用户立场文本:不要虚构一个立场来对攻,只按维度攻击。)",
|
|
2086
2122
|
"",
|
|
2087
2123
|
"约束:",
|
|
2088
2124
|
`- 只攻击「${dimension}」维度,不要越界到别人负责的维度;`,
|
|
@@ -2090,7 +2126,7 @@ function expertWelcome(input) {
|
|
|
2090
2126
|
"- 不要开场白、不要总结、不要夸奖;",
|
|
2091
2127
|
"- 一条缺陷一条发言;",
|
|
2092
2128
|
"- 代码/行为类缺陷给 `repro`(编号且真的可执行),设计/取舍类缺陷给 `argument`(论证链),**禁止为设计类缺陷编造伪复现步骤**;",
|
|
2093
|
-
hasText(stance) ? "- 逐条回应用户立场里的每一条理由,打不穿就如实标 `false`;" : "- 不要虚构用户立场;",
|
|
2129
|
+
hasText$1(stance) ? "- 逐条回应用户立场里的每一条理由,打不穿就如实标 `false`;" : "- 不要虚构用户立场;",
|
|
2094
2130
|
"- 只用只读工具核查,你没有写权限,不要试图修改工作区。",
|
|
2095
2131
|
"",
|
|
2096
2132
|
"期望产出:",
|
|
@@ -2181,7 +2217,8 @@ async function spawnExpert(ctx, config, record, expert, parent, signal, limits =
|
|
|
2181
2217
|
stance: record.stance.text,
|
|
2182
2218
|
dimensions: record.dimensions,
|
|
2183
2219
|
maxOpinions: limits.maxOpinions ?? 0,
|
|
2184
|
-
...expert.role === void 0 ? {} : { role: expert.role }
|
|
2220
|
+
...expert.role === void 0 ? {} : { role: expert.role },
|
|
2221
|
+
...expert.prompt === void 0 ? {} : { prompt: expert.prompt }
|
|
2185
2222
|
};
|
|
2186
2223
|
const route = resolveExpertRoute(expert, config);
|
|
2187
2224
|
const agentOptions = { ...route.agentOptions };
|
|
@@ -2244,13 +2281,13 @@ async function spawnExpert(ctx, config, record, expert, parent, signal, limits =
|
|
|
2244
2281
|
* @module dsh-plugin-crossfire/version
|
|
2245
2282
|
*/
|
|
2246
2283
|
/** 当前插件版本(必须与 package.json 的 version 逐字一致)。 */
|
|
2247
|
-
const PLUGIN_VERSION = "0.
|
|
2284
|
+
const PLUGIN_VERSION = "0.2.0";
|
|
2248
2285
|
/** 导出物头部使用的插件标识行。 */
|
|
2249
2286
|
const PLUGIN_ID = `@y9931666/dsh-plugin-crossfire v${PLUGIN_VERSION}`;
|
|
2250
2287
|
/** DeepSeek Harness 版本区间(导出头部展示用)。
|
|
2251
2288
|
* 必须与 `compatibility.json` 的 `recommendedHost` 一致 —— 由
|
|
2252
2289
|
* `test/version.test.mjs` 强制。 */
|
|
2253
|
-
const HARNESS_RANGE = "DeepSeek Harness 0.2.
|
|
2290
|
+
const HARNESS_RANGE = "DeepSeek Harness 0.2.2-rc.1+";
|
|
2254
2291
|
|
|
2255
2292
|
//#endregion
|
|
2256
2293
|
//#region src/export-md.ts
|
|
@@ -2756,7 +2793,8 @@ function registerRpc(ctx, runtime) {
|
|
|
2756
2793
|
stance: located.record.stance.text,
|
|
2757
2794
|
dimensions: located.record.dimensions,
|
|
2758
2795
|
maxOpinions: runtime.tools.getDefaults().expertMaxOpinions,
|
|
2759
|
-
...expert.role === void 0 ? {} : { role: expert.role }
|
|
2796
|
+
...expert.role === void 0 ? {} : { role: expert.role },
|
|
2797
|
+
...expert.prompt === void 0 ? {} : { prompt: expert.prompt }
|
|
2760
2798
|
});
|
|
2761
2799
|
return ok({
|
|
2762
2800
|
expertKey,
|
|
@@ -2873,7 +2911,7 @@ async function advance(ctx, runtime, located, step, body, session) {
|
|
|
2873
2911
|
const running = current.experts.filter((expert) => expert.status === "running");
|
|
2874
2912
|
if (running.length > 0) return fail(`experts are already running for this review (${running.map((expert) => expert.key).join(", ")}) — wait for them to report, or collect what they have already submitted`);
|
|
2875
2913
|
const prefs = runtime.prefs.get();
|
|
2876
|
-
const
|
|
2914
|
+
const preferencesRoster = rosterFrom({
|
|
2877
2915
|
squads: prefs.squads.map((squad) => ({
|
|
2878
2916
|
id: squad.id,
|
|
2879
2917
|
members: squad.members.map((member) => ({
|
|
@@ -2890,6 +2928,7 @@ async function advance(ctx, runtime, located, step, body, session) {
|
|
|
2890
2928
|
...preset.model === void 0 ? {} : { model: preset.model }
|
|
2891
2929
|
}))
|
|
2892
2930
|
});
|
|
2931
|
+
const roster = current.expertRoster !== void 0 && current.expertRoster.length > 0 ? current.expertRoster : preferencesRoster;
|
|
2893
2932
|
const experts = planExperts(current, runtime.tools.maxExperts, roster);
|
|
2894
2933
|
if (current.reviewPass > 1 && current.history.length > 0 && current.defects.length === 0) current.defects = buildRecheckItems(current);
|
|
2895
2934
|
current.experts = experts;
|
|
@@ -3037,6 +3076,136 @@ async function openReviewFromRpc(ctx, runtime, input) {
|
|
|
3037
3076
|
return ok({ review: snapshotOf(record, stateRoot) });
|
|
3038
3077
|
}
|
|
3039
3078
|
|
|
3079
|
+
//#endregion
|
|
3080
|
+
//#region src/plan.ts
|
|
3081
|
+
/** 卡片上"批准"选项的固定标签;`intent.approve` 必须与它逐字一致。 */
|
|
3082
|
+
const PLAN_APPROVE_LABEL = "确定开始";
|
|
3083
|
+
/** 卡片上的次选项:用户想改点什么。 */
|
|
3084
|
+
const PLAN_REVISE_LABEL = "仍需修改";
|
|
3085
|
+
/** 待验证正文在卡片上最多显示的字数(超出截断,完整正文随评审落盘)。 */
|
|
3086
|
+
const SUBJECT_PREVIEW_MAX = 1200;
|
|
3087
|
+
/** 提示词摘要的最大字数(表 2 的「提示词」列)。 */
|
|
3088
|
+
const PROMPT_SUMMARY_MAX = 40;
|
|
3089
|
+
/** 立场摘要的最大字数(表 1 的「立场」行)。 */
|
|
3090
|
+
const STANCE_SUMMARY_MAX = 60;
|
|
3091
|
+
/** 非空判定:`undefined` 与纯空白都算"没写"。 */
|
|
3092
|
+
function hasText(value) {
|
|
3093
|
+
return value !== void 0 && value.trim() !== "";
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* Markdown 表格单元格转义。
|
|
3097
|
+
*
|
|
3098
|
+
* 为什么必须做:`|` 会把一行劈成多一列(整张表错位),换行会直接终结表格
|
|
3099
|
+
* 结构(后面的内容全变成表外散文)。角色与提示词完全由用户书写,两样都可能
|
|
3100
|
+
* 出现,所以每一个**进入表格的**值都必须过这一层。
|
|
3101
|
+
*/
|
|
3102
|
+
function cell(value) {
|
|
3103
|
+
return value.replace(/\r\n|\r|\n/g, " ").replace(/\|/g, "\\|").trim();
|
|
3104
|
+
}
|
|
3105
|
+
/** 多行文本压成一行(只用于摘要,避免摘要里出现换行)。 */
|
|
3106
|
+
function flatten(value) {
|
|
3107
|
+
return value.replace(/\s+/g, " ").trim();
|
|
3108
|
+
}
|
|
3109
|
+
/** 截断到上限并加省略号(摘要类文本用;全文不截断)。 */
|
|
3110
|
+
function summarize(value, max) {
|
|
3111
|
+
const text$1 = flatten(value);
|
|
3112
|
+
return text$1.length <= max ? text$1 : `${text$1.slice(0, max)}…`;
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* 模型路由在卡片上的写法。
|
|
3116
|
+
*
|
|
3117
|
+
* 四种组合刻意写出**不同的**标签,因为"没填"与"只填了一半"在真实评审里的行为
|
|
3118
|
+
* 不同(见 `members.ts` 的 `resolveExpertRoute`):只填 provider 时由 provider
|
|
3119
|
+
* 自选模型,只填 model 时补继承的 provider。把这些折叠成一句"继承主持人"会让
|
|
3120
|
+
* 用户以为自己的路由没生效。
|
|
3121
|
+
*/
|
|
3122
|
+
function routeLabel(expert) {
|
|
3123
|
+
const provider = hasText(expert.provider) ? expert.provider.trim() : "";
|
|
3124
|
+
const model = hasText(expert.model) ? expert.model.trim() : "";
|
|
3125
|
+
if (provider !== "" && model !== "") return `${provider}/${model}`;
|
|
3126
|
+
if (provider !== "") return `${provider}(模型随该 provider 默认)`;
|
|
3127
|
+
if (model !== "") return `${model}(provider 继承主持人)`;
|
|
3128
|
+
return "继承主持人";
|
|
3129
|
+
}
|
|
3130
|
+
/** 表 2 的「提示词」列:填了就显示前 40 字,没填明说"自动"。 */
|
|
3131
|
+
function promptSummary(prompt) {
|
|
3132
|
+
return hasText(prompt) ? summarize(prompt, PROMPT_SUMMARY_MAX) : "自动(九条硬约束)";
|
|
3133
|
+
}
|
|
3134
|
+
/** 表 1 的「来源」行。 */
|
|
3135
|
+
function sourceLabel(draft) {
|
|
3136
|
+
const ref = hasText(draft.sourceRef) ? draft.sourceRef.trim() : "";
|
|
3137
|
+
return ref === "" ? draft.source : `${draft.source}(引用:${ref})`;
|
|
3138
|
+
}
|
|
3139
|
+
/** 表 1 的「强度」行:把这一档真正生效的数字写在同行,用户不必再查设置页。 */
|
|
3140
|
+
function intensityLabel(draft) {
|
|
3141
|
+
const spec = INTENSITY_SPECS[draft.intensity];
|
|
3142
|
+
return `${draft.intensity}(每维 ${spec.expertsPerDimension} 位专家 / 复审上限 ${spec.maxReviewPass} / 预算 ${draft.maxRounds} 轮 · ${draft.maxTokens} tokens)`;
|
|
3143
|
+
}
|
|
3144
|
+
/** 表 1 的「立场」行:有就前 60 字摘要,没有就明说「无」。 */
|
|
3145
|
+
function stanceLabel(stance) {
|
|
3146
|
+
return hasText(stance) ? `有(${summarize(stance, STANCE_SUMMARY_MAX)})` : "无";
|
|
3147
|
+
}
|
|
3148
|
+
/** 渲染「本次变更」列表(空清单明说"未标注",不假装有变更)。 */
|
|
3149
|
+
function changeLines(changes) {
|
|
3150
|
+
const items = changes.map((change) => flatten(change)).filter((change) => change !== "");
|
|
3151
|
+
if (items.length === 0) return ["> - (本次未标注具体变更)"];
|
|
3152
|
+
return items.map((change) => `> - ${change}`);
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* 把一份草案渲染成卡片正文(Markdown,放 `AskUserQuestionItem.detail`)。
|
|
3156
|
+
*
|
|
3157
|
+
* 结构固定为:抬头 →(revised 时)本次变更 → 表 1 评审设置 → 表 2 红队阵容 →
|
|
3158
|
+
* 待验证正文 → 立场全文 → 修改说明。**顺序有意义**:先给"怎么评",再给"评什么",
|
|
3159
|
+
* 最后才是"怎么改这张卡"。
|
|
3160
|
+
*/
|
|
3161
|
+
function formatReviewDraft(draft) {
|
|
3162
|
+
const title = draft.title.trim() === "" ? "(未命名评审)" : draft.title.trim();
|
|
3163
|
+
const lines = [draft.revised ? `# 评审设置确认 · ${title}(已按你的意见更新)` : `# 评审设置确认 · ${title}`, ""];
|
|
3164
|
+
if (draft.revised) lines.push("> 本次变更:", ...changeLines(draft.changes), "");
|
|
3165
|
+
else lines.push("> 以下是即将开始的评审设置。**确认后才会真正开始评审。**", "");
|
|
3166
|
+
lines.push("## 表 1 · 评审设置", "", "| 项 | 值 |", "|---|---|", `| 评审标题 | \`${cell(title)}\` |`, `| 来源 | ${cell(sourceLabel(draft))} |`, `| 强度 | ${cell(intensityLabel(draft))} |`, `| 维度 | ${cell(draft.dimensions.length === 0 ? "(未指定)" : `${draft.dimensions.join("、")}(${draft.dimensions.length} 个)`)} |`, `| 立场 | ${cell(stanceLabel(draft.stance))} |`, `| 红队人数 | ${draft.experts.length} 位 |`, "", "## 表 2 · 红队阵容", "", "| # | 维度 | 专家 key | 模型 | 角色 | 提示词 |", "|---|---|---|---|---|---|");
|
|
3167
|
+
if (draft.experts.length === 0) lines.push("| — | (无) | — | — | — | — |");
|
|
3168
|
+
else draft.experts.forEach((expert, index) => {
|
|
3169
|
+
lines.push(`| ${index + 1} | ${cell(expert.dimension)} | \`${cell(expert.key)}\` | ${cell(routeLabel(expert))} | ${cell(hasText(expert.role) ? expert.role.trim() : "(未填)")} | ${cell(promptSummary(expert.prompt))} |`);
|
|
3170
|
+
});
|
|
3171
|
+
lines.push("", "## 待验证正文", "");
|
|
3172
|
+
const content = draft.content.trim();
|
|
3173
|
+
if (content === "") lines.push("(未提供正文)");
|
|
3174
|
+
else if (content.length > SUBJECT_PREVIEW_MAX) lines.push(content.slice(0, SUBJECT_PREVIEW_MAX), "", `…(正文超过 ${SUBJECT_PREVIEW_MAX} 字,此处已截断;**完整正文随评审落盘**,评审用的是全文。)`);
|
|
3175
|
+
else lines.push(content);
|
|
3176
|
+
lines.push("", "## 立场全文", "");
|
|
3177
|
+
lines.push(hasText(draft.stance) ? draft.stance.trim() : "(本次未填写立场:红队只按维度攻击,不做立场对攻。)");
|
|
3178
|
+
lines.push("", "## 修改说明", "", `需要改哪一项就选「${PLAN_REVISE_LABEL}」,并在输入框写清改什么(例:把「安全」这一行的模型换成 zai-coding-cn/glm-5.2、强度改成 quick、正文补上第三节)。`, "主持人据此更新草案后**会再弹一次**这张卡片;确认前不会创建任何评审。");
|
|
3179
|
+
return lines.join("\n");
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* 解析用户的卡片回答。
|
|
3183
|
+
*
|
|
3184
|
+
* 协议与 `intent.kind: 'plan-review'` 的语义一致:`selected` 含
|
|
3185
|
+
* `PLAN_APPROVE_LABEL` 即视为批准,其余选项(或只有 custom 文本)视为要求修改。
|
|
3186
|
+
* 不识别 intent 的 UI 会退回普通选项列表,答复编码完全相同。
|
|
3187
|
+
*
|
|
3188
|
+
* 纯空白 custom 不算"要求修改"(那只是用户点错了),统一归 unavailable ——
|
|
3189
|
+
* 主持人会改用文字再确认一次,而不是拿一句空话去改草案。
|
|
3190
|
+
*/
|
|
3191
|
+
function resolvePlanConfirmation(answer, approveLabel = PLAN_APPROVE_LABEL) {
|
|
3192
|
+
if (answer === void 0) return {
|
|
3193
|
+
kind: "unavailable",
|
|
3194
|
+
note: ""
|
|
3195
|
+
};
|
|
3196
|
+
const selected = Array.isArray(answer.selected) ? answer.selected : [];
|
|
3197
|
+
const note = answer.custom === void 0 ? "" : answer.custom.trim();
|
|
3198
|
+
if (selected.includes(approveLabel)) return { kind: "approved" };
|
|
3199
|
+
if (note !== "") return {
|
|
3200
|
+
kind: "revise",
|
|
3201
|
+
note
|
|
3202
|
+
};
|
|
3203
|
+
return {
|
|
3204
|
+
kind: "unavailable",
|
|
3205
|
+
note: ""
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3040
3209
|
//#endregion
|
|
3041
3210
|
//#region src/tools.ts
|
|
3042
3211
|
/** 调用者 agent,或一个响亮的失败(非 agent 调用)。 */
|
|
@@ -3066,6 +3235,125 @@ function stringArray(value) {
|
|
|
3066
3235
|
function parseIntensity(value) {
|
|
3067
3236
|
return value === "quick" || value === "standard" || value === "deep" ? value : void 0;
|
|
3068
3237
|
}
|
|
3238
|
+
/**
|
|
3239
|
+
* 把 `experts[]` 参数读成确认卡上的"草案行"。
|
|
3240
|
+
*
|
|
3241
|
+
* 维度经 `resolveDimension` 归一化到本次采用的维度:用户在卡上写「安全风险」也要
|
|
3242
|
+
* 能对上「安全」,否则一行配置会因为这个后缀被静默丢掉。
|
|
3243
|
+
*
|
|
3244
|
+
* `key` 允许为空(缺席),表示"沿用默认的维度名"——用户通常只改模型或提示词。
|
|
3245
|
+
*/
|
|
3246
|
+
function parseDraftExpertRows(value, dimensions) {
|
|
3247
|
+
if (!Array.isArray(value)) return [];
|
|
3248
|
+
const rows = [];
|
|
3249
|
+
for (const raw of value) {
|
|
3250
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
3251
|
+
const entry = raw;
|
|
3252
|
+
const dimensionRaw = text(entry.dimension);
|
|
3253
|
+
if (dimensionRaw === "") continue;
|
|
3254
|
+
const row = {
|
|
3255
|
+
key: text(entry.key),
|
|
3256
|
+
dimension: resolveDimension(dimensionRaw, dimensions).dimension
|
|
3257
|
+
};
|
|
3258
|
+
const role = text(entry.role);
|
|
3259
|
+
if (role !== "") row.role = role;
|
|
3260
|
+
const provider = text(entry.provider);
|
|
3261
|
+
if (provider !== "") row.provider = provider;
|
|
3262
|
+
const model = text(entry.model);
|
|
3263
|
+
if (model !== "") row.model = model;
|
|
3264
|
+
const prompt = text(entry.prompt);
|
|
3265
|
+
if (prompt !== "") row.prompt = prompt;
|
|
3266
|
+
rows.push(row);
|
|
3267
|
+
}
|
|
3268
|
+
return rows;
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* 默认阵容:每维一位(等价于 `crossfire_attack` 不带 roster 时的结果),不落盘。
|
|
3272
|
+
*
|
|
3273
|
+
* 借 `newRecord` 造一份**不写盘**的临时记录:`planExperts` 只读 `dimensions` 与
|
|
3274
|
+
* `intensity`,用真构造器而不是手工拼对象,是为了让卡片上的默认阵容与真正开打时
|
|
3275
|
+
* 走**同一段**规划逻辑(复制一份必然分叉)。
|
|
3276
|
+
*/
|
|
3277
|
+
function defaultDraftExperts(dimensions, intensity, maxRounds, maxTokens, maxExperts) {
|
|
3278
|
+
return planExperts(newRecord({
|
|
3279
|
+
reviewId: "plan-preview",
|
|
3280
|
+
title: "",
|
|
3281
|
+
content: "",
|
|
3282
|
+
source: "manual",
|
|
3283
|
+
dimensions,
|
|
3284
|
+
intensity,
|
|
3285
|
+
maxRounds,
|
|
3286
|
+
maxTokens
|
|
3287
|
+
}), maxExperts).map((expert) => ({
|
|
3288
|
+
key: expert.key,
|
|
3289
|
+
dimension: expert.dimension
|
|
3290
|
+
}));
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* 把用户提供的行合并进默认阵容(**只覆盖给了的列**)。
|
|
3294
|
+
*
|
|
3295
|
+
* 匹配顺序与 `planExperts` 一致:先按 key,再按维度。用户没提到的行保持默认值 ——
|
|
3296
|
+
* 卡片上"未指定的列沿用设置页阵容/继承"这句话就是这条规则。
|
|
3297
|
+
*
|
|
3298
|
+
* 同一维度上用户显式加了第二位专家时**追加**(粒度由用户决定),最后按专家上限截断。
|
|
3299
|
+
*/
|
|
3300
|
+
function mergeDraftExperts(defaults, provided, maxExperts) {
|
|
3301
|
+
const merged = defaults.map((expert) => ({ ...expert }));
|
|
3302
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
3303
|
+
for (const row of provided) {
|
|
3304
|
+
let target = row.key === "" ? -1 : merged.findIndex((expert, index) => !claimed.has(index) && expert.key === row.key);
|
|
3305
|
+
if (target < 0) target = merged.findIndex((expert, index) => !claimed.has(index) && expert.dimension === row.dimension);
|
|
3306
|
+
if (target >= 0) {
|
|
3307
|
+
claimed.add(target);
|
|
3308
|
+
const current = merged[target];
|
|
3309
|
+
if (current === void 0) continue;
|
|
3310
|
+
const next = { ...current };
|
|
3311
|
+
if (row.key !== "") next.key = row.key;
|
|
3312
|
+
if (row.role !== void 0) next.role = row.role;
|
|
3313
|
+
if (row.provider !== void 0) next.provider = row.provider;
|
|
3314
|
+
if (row.model !== void 0) next.model = row.model;
|
|
3315
|
+
if (row.prompt !== void 0) next.prompt = row.prompt;
|
|
3316
|
+
merged[target] = next;
|
|
3317
|
+
} else {
|
|
3318
|
+
merged.push({
|
|
3319
|
+
...row,
|
|
3320
|
+
key: row.key === "" ? row.dimension : row.key
|
|
3321
|
+
});
|
|
3322
|
+
claimed.add(merged.length - 1);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
return merged.length > maxExperts ? merged.slice(0, maxExperts) : merged;
|
|
3326
|
+
}
|
|
3327
|
+
/**
|
|
3328
|
+
* 把 `experts[]` 参数读成**落盘的**阵容条目(只存非空字段)。
|
|
3329
|
+
*
|
|
3330
|
+
* 与确认卡的 `DraftExpert` 是同一份形状,但这里落的是 `ExpertRosterEntry`:
|
|
3331
|
+
* "怎么配一位专家"。`dimension` 是比 `key` 更硬的匹配键(见 engine.ts 的
|
|
3332
|
+
* `planExperts`),所以用户给专家换个名字也不会让这一行配置对不上。
|
|
3333
|
+
*/
|
|
3334
|
+
function rosterFromArguments(value) {
|
|
3335
|
+
if (!Array.isArray(value)) return [];
|
|
3336
|
+
const entries = [];
|
|
3337
|
+
for (const raw of value) {
|
|
3338
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
3339
|
+
const entry = raw;
|
|
3340
|
+
const dimension = text(entry.dimension);
|
|
3341
|
+
const key = text(entry.key) || dimension;
|
|
3342
|
+
if (key === "") continue;
|
|
3343
|
+
const roster = { key };
|
|
3344
|
+
if (dimension !== "") roster.dimension = dimension;
|
|
3345
|
+
const role = text(entry.role);
|
|
3346
|
+
if (role !== "") roster.role = role;
|
|
3347
|
+
const provider = text(entry.provider);
|
|
3348
|
+
if (provider !== "") roster.provider = provider;
|
|
3349
|
+
const model = text(entry.model);
|
|
3350
|
+
if (model !== "") roster.model = model;
|
|
3351
|
+
const prompt = text(entry.prompt);
|
|
3352
|
+
if (prompt !== "") roster.prompt = prompt;
|
|
3353
|
+
entries.push(roster);
|
|
3354
|
+
}
|
|
3355
|
+
return entries;
|
|
3356
|
+
}
|
|
3069
3357
|
/** 读一场评审,不存在就抛错。 */
|
|
3070
3358
|
async function requireRecord(stateRoot, reviewId) {
|
|
3071
3359
|
const record = await readRecord(stateRoot, reviewId);
|
|
@@ -3101,9 +3389,286 @@ function registerCrossfireTools(ctx, config) {
|
|
|
3101
3389
|
...config.defaultProvider === void 0 ? {} : { defaultProvider: config.defaultProvider },
|
|
3102
3390
|
...config.defaultModel === void 0 ? {} : { defaultModel: config.defaultModel }
|
|
3103
3391
|
};
|
|
3392
|
+
ctx.tools.register(defineTool({
|
|
3393
|
+
name: "crossfire_plan_review",
|
|
3394
|
+
description: "Show the human a REVIEW SETTINGS CARD before opening anything (step 0 of the flow). This tool does NOT create a review: it composes the draft (title, subject, source, intensity, dimensions, stance, one red-team expert per dimension with optional role/model/prompt, budget) into two markdown tables and BLOCKS until the user confirms. Returns decision=\"approved\" plus the full confirmed config to pass to crossfire_open (including experts), or decision=\"revise\" with the user's own words — update the draft, add changes[] and call this tool again (the card is shown again with the change list on top). Call it for EVERY review, even a single-expert one.",
|
|
3395
|
+
parameters: {
|
|
3396
|
+
title: {
|
|
3397
|
+
type: "string",
|
|
3398
|
+
required: true,
|
|
3399
|
+
description: "One-line title of the thing under review (shown on the card and in the export)."
|
|
3400
|
+
},
|
|
3401
|
+
content: {
|
|
3402
|
+
type: "string",
|
|
3403
|
+
required: true,
|
|
3404
|
+
description: "The settled result / plan / artifact itself: paste the actual text to be attacked."
|
|
3405
|
+
},
|
|
3406
|
+
dimensions: {
|
|
3407
|
+
type: "array",
|
|
3408
|
+
items: { type: "string" },
|
|
3409
|
+
description: "Dimensions to attack from, e.g. [\"安全\",\"性能\"]. Omit to use the full pool (安全/数据/交互/性能/兼容/流程/反例 + your custom ones). Unknown values are normalized to the nearest pool value."
|
|
3410
|
+
},
|
|
3411
|
+
intensity: {
|
|
3412
|
+
type: "string",
|
|
3413
|
+
enum: [
|
|
3414
|
+
"quick",
|
|
3415
|
+
"standard",
|
|
3416
|
+
"deep"
|
|
3417
|
+
],
|
|
3418
|
+
description: "Attack intensity: quick (one round), standard (default), deep (adds a cross-examination round, allows two re-reviews)."
|
|
3419
|
+
},
|
|
3420
|
+
stance: {
|
|
3421
|
+
type: "string",
|
|
3422
|
+
description: "YOUR position and reasons on this subject, in your own words. When present, the red team must argue against each reason and mark whether it was refuted (strikes_stance). This is what turns a neutral review into 针锋相对."
|
|
3423
|
+
},
|
|
3424
|
+
source: {
|
|
3425
|
+
type: "string",
|
|
3426
|
+
enum: [
|
|
3427
|
+
"manual",
|
|
3428
|
+
"chat",
|
|
3429
|
+
"file"
|
|
3430
|
+
],
|
|
3431
|
+
description: "Where the subject came from (default manual)."
|
|
3432
|
+
},
|
|
3433
|
+
source_ref: {
|
|
3434
|
+
type: "string",
|
|
3435
|
+
description: "Optional reference for the source (a file path, or a short anchor)."
|
|
3436
|
+
},
|
|
3437
|
+
source_meeting: {
|
|
3438
|
+
type: "string",
|
|
3439
|
+
description: "Optional: the roundtable meeting id this review was handed over from."
|
|
3440
|
+
},
|
|
3441
|
+
experts: {
|
|
3442
|
+
type: "array",
|
|
3443
|
+
description: "Red-team roster shown on the card. Omit for the default: one expert per chosen dimension (key = dimension name, inheriting your route). Rows match by key, then by dimension; columns you leave out keep the default (inherit the host route, no extra role/prompt).",
|
|
3444
|
+
items: {
|
|
3445
|
+
type: "object",
|
|
3446
|
+
additionalProperties: false,
|
|
3447
|
+
properties: {
|
|
3448
|
+
key: {
|
|
3449
|
+
type: "string",
|
|
3450
|
+
description: "Expert key inside the review (default: the dimension name)."
|
|
3451
|
+
},
|
|
3452
|
+
dimension: {
|
|
3453
|
+
type: "string",
|
|
3454
|
+
required: true,
|
|
3455
|
+
description: "The one dimension this expert attacks."
|
|
3456
|
+
},
|
|
3457
|
+
role: {
|
|
3458
|
+
type: "string",
|
|
3459
|
+
description: "Short supplementary role, e.g. \"密钥与权限\". Narrows expertise only — it can never relax the read-only / no-plan / no-flattery rules."
|
|
3460
|
+
},
|
|
3461
|
+
provider: {
|
|
3462
|
+
type: "string",
|
|
3463
|
+
description: "Optional model provider route."
|
|
3464
|
+
},
|
|
3465
|
+
model: {
|
|
3466
|
+
type: "string",
|
|
3467
|
+
description: "Optional model id."
|
|
3468
|
+
},
|
|
3469
|
+
prompt: {
|
|
3470
|
+
type: "string",
|
|
3471
|
+
description: "Free-text supplementary prompt for this expert. Narrows expertise only — it can never relax the hard constraints."
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
},
|
|
3476
|
+
changes: {
|
|
3477
|
+
type: "array",
|
|
3478
|
+
items: { type: "string" },
|
|
3479
|
+
description: "What changed since the previous draft (only meaningful when revised=true). Be specific, e.g. \"reviewer model: zai/glm-5.2 → deepseek/deepseek-chat\". An empty list while revised=true shows \"(本次未标注具体变更)\" — do not fake changes."
|
|
3480
|
+
},
|
|
3481
|
+
revised: {
|
|
3482
|
+
type: "boolean",
|
|
3483
|
+
description: "Set true when re-showing the card after the user asked for changes (the card then lists changes[] on top)."
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
output: {
|
|
3487
|
+
schema: {
|
|
3488
|
+
type: "object",
|
|
3489
|
+
additionalProperties: false,
|
|
3490
|
+
properties: {
|
|
3491
|
+
decision: {
|
|
3492
|
+
type: "string",
|
|
3493
|
+
required: true
|
|
3494
|
+
},
|
|
3495
|
+
user_note: {
|
|
3496
|
+
type: "string",
|
|
3497
|
+
required: true
|
|
3498
|
+
},
|
|
3499
|
+
title: {
|
|
3500
|
+
type: "string",
|
|
3501
|
+
required: true
|
|
3502
|
+
},
|
|
3503
|
+
content: {
|
|
3504
|
+
type: "string",
|
|
3505
|
+
required: true
|
|
3506
|
+
},
|
|
3507
|
+
dimensions: {
|
|
3508
|
+
type: "array",
|
|
3509
|
+
items: { type: "string" },
|
|
3510
|
+
required: true
|
|
3511
|
+
},
|
|
3512
|
+
intensity: {
|
|
3513
|
+
type: "string",
|
|
3514
|
+
required: true
|
|
3515
|
+
},
|
|
3516
|
+
stance: {
|
|
3517
|
+
type: "string",
|
|
3518
|
+
required: true
|
|
3519
|
+
},
|
|
3520
|
+
source: {
|
|
3521
|
+
type: "string",
|
|
3522
|
+
required: true
|
|
3523
|
+
},
|
|
3524
|
+
source_ref: {
|
|
3525
|
+
type: "string",
|
|
3526
|
+
required: true
|
|
3527
|
+
},
|
|
3528
|
+
source_meeting: {
|
|
3529
|
+
type: "string",
|
|
3530
|
+
required: true
|
|
3531
|
+
},
|
|
3532
|
+
experts: {
|
|
3533
|
+
type: "array",
|
|
3534
|
+
required: true,
|
|
3535
|
+
items: {
|
|
3536
|
+
type: "object",
|
|
3537
|
+
additionalProperties: false,
|
|
3538
|
+
properties: {
|
|
3539
|
+
key: {
|
|
3540
|
+
type: "string",
|
|
3541
|
+
required: true
|
|
3542
|
+
},
|
|
3543
|
+
dimension: {
|
|
3544
|
+
type: "string",
|
|
3545
|
+
required: true
|
|
3546
|
+
},
|
|
3547
|
+
role: {
|
|
3548
|
+
type: "string",
|
|
3549
|
+
required: true
|
|
3550
|
+
},
|
|
3551
|
+
provider: {
|
|
3552
|
+
type: "string",
|
|
3553
|
+
required: true
|
|
3554
|
+
},
|
|
3555
|
+
model: {
|
|
3556
|
+
type: "string",
|
|
3557
|
+
required: true
|
|
3558
|
+
},
|
|
3559
|
+
prompt: {
|
|
3560
|
+
type: "string",
|
|
3561
|
+
required: true
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
},
|
|
3566
|
+
max_rounds: {
|
|
3567
|
+
type: "integer",
|
|
3568
|
+
required: true
|
|
3569
|
+
},
|
|
3570
|
+
max_tokens: {
|
|
3571
|
+
type: "integer",
|
|
3572
|
+
required: true
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
},
|
|
3576
|
+
render: (_args, value) => [{
|
|
3577
|
+
type: "text",
|
|
3578
|
+
text: value.decision === "approved" ? `用户已确认评审设置:${value.title}(${value.intensity},维度 ${value.dimensions.join(", ")},${value.experts.length} 位红队专家,${value.max_rounds} 轮 / ${value.max_tokens} tokens)。请用 crossfire_open 按这些值开启评审(experts 原样传入),然后 crossfire_attack。` : value.decision === "revise" ? `用户要求修改:${value.user_note}\n请据此更新草案(保持未改动的项原样),带上 changes[] 再次调用 crossfire_plan_review 让用户确认;确认前不要 crossfire_open。` : `设置卡片未能取得用户答复(userQuestions 服务不可用或被中止)${value.user_note === "" ? "" : `:${value.user_note}`}。请把配置用文字告知用户,取得明确同意后再调用 crossfire_open。`
|
|
3579
|
+
}]
|
|
3580
|
+
},
|
|
3581
|
+
async execute(args, exec) {
|
|
3582
|
+
const caller = requireAgent(exec);
|
|
3583
|
+
const defaults = config.getDefaults();
|
|
3584
|
+
const content = text(args.content);
|
|
3585
|
+
if (content === "") throw new Error("content must not be empty — paste the thing under review");
|
|
3586
|
+
const title = text(args.title) || content.slice(0, 40);
|
|
3587
|
+
const intensity = parseIntensity(args.intensity) ?? defaults.intensity;
|
|
3588
|
+
const dimensions = resolveDimensions(stringArray(args.dimensions), defaults.customDimensions);
|
|
3589
|
+
const source = args.source === "chat" || args.source === "file" ? args.source : "manual";
|
|
3590
|
+
const sourceRef = text(args.source_ref);
|
|
3591
|
+
const sourceMeeting = text(args.source_meeting);
|
|
3592
|
+
const stance = text(args.stance).slice(0, 2e3);
|
|
3593
|
+
const revised = args.revised === true;
|
|
3594
|
+
const changes = (args.changes ?? []).map((item) => item.trim()).filter((item) => item !== "");
|
|
3595
|
+
const experts = mergeDraftExperts(defaultDraftExperts(dimensions, intensity, defaults.maxRounds, defaults.maxTokens, config.maxExperts), parseDraftExpertRows(args.experts, dimensions), config.maxExperts);
|
|
3596
|
+
const draft = {
|
|
3597
|
+
title,
|
|
3598
|
+
content,
|
|
3599
|
+
source,
|
|
3600
|
+
...sourceRef === "" ? {} : { sourceRef },
|
|
3601
|
+
dimensions,
|
|
3602
|
+
intensity,
|
|
3603
|
+
stance,
|
|
3604
|
+
experts,
|
|
3605
|
+
maxRounds: defaults.maxRounds,
|
|
3606
|
+
maxTokens: defaults.maxTokens,
|
|
3607
|
+
changes,
|
|
3608
|
+
revised
|
|
3609
|
+
};
|
|
3610
|
+
const detail = formatReviewDraft(draft);
|
|
3611
|
+
const configured = {
|
|
3612
|
+
title,
|
|
3613
|
+
content,
|
|
3614
|
+
dimensions,
|
|
3615
|
+
intensity,
|
|
3616
|
+
stance,
|
|
3617
|
+
source,
|
|
3618
|
+
source_ref: sourceRef,
|
|
3619
|
+
source_meeting: sourceMeeting,
|
|
3620
|
+
experts: draft.experts.map((expert) => ({
|
|
3621
|
+
key: expert.key,
|
|
3622
|
+
dimension: expert.dimension,
|
|
3623
|
+
role: expert.role ?? "",
|
|
3624
|
+
provider: expert.provider ?? "",
|
|
3625
|
+
model: expert.model ?? "",
|
|
3626
|
+
prompt: expert.prompt ?? ""
|
|
3627
|
+
})),
|
|
3628
|
+
max_rounds: defaults.maxRounds,
|
|
3629
|
+
max_tokens: defaults.maxTokens
|
|
3630
|
+
};
|
|
3631
|
+
const answerWith = (decision, note) => ({
|
|
3632
|
+
decision,
|
|
3633
|
+
user_note: note,
|
|
3634
|
+
...configured
|
|
3635
|
+
});
|
|
3636
|
+
const userQuestions = ctx.get("userQuestions");
|
|
3637
|
+
if (userQuestions === void 0) return answerWith("unavailable", "the userQuestions service is not mounted in this composition");
|
|
3638
|
+
const questionId = randomUUID();
|
|
3639
|
+
let answer;
|
|
3640
|
+
try {
|
|
3641
|
+
answer = await userQuestions.ask({
|
|
3642
|
+
questions: [{
|
|
3643
|
+
id: questionId,
|
|
3644
|
+
question: `确认「${title}」的评审设置?`,
|
|
3645
|
+
detail,
|
|
3646
|
+
header: "针锋 Crossfire · 评审设置确认",
|
|
3647
|
+
options: [{
|
|
3648
|
+
label: PLAN_APPROVE_LABEL,
|
|
3649
|
+
description: "按上方两张表开始评审(红队随后由主持人拉起)"
|
|
3650
|
+
}, {
|
|
3651
|
+
label: PLAN_REVISE_LABEL,
|
|
3652
|
+
description: "在输入框写明要改的地方,主持人会更新草案并再弹一次"
|
|
3653
|
+
}],
|
|
3654
|
+
intent: {
|
|
3655
|
+
kind: "plan-review",
|
|
3656
|
+
approve: PLAN_APPROVE_LABEL
|
|
3657
|
+
}
|
|
3658
|
+
}],
|
|
3659
|
+
agent: caller,
|
|
3660
|
+
signal: exec.signal
|
|
3661
|
+
});
|
|
3662
|
+
} catch (error) {
|
|
3663
|
+
return answerWith("unavailable", `设置卡片不可用:${error.message}`);
|
|
3664
|
+
}
|
|
3665
|
+
const confirmation = resolvePlanConfirmation((Array.isArray(answer.answers) ? answer.answers : []).find((candidate) => candidate.id === questionId));
|
|
3666
|
+
return answerWith(confirmation.kind, confirmation.kind === "approved" ? "" : confirmation.note);
|
|
3667
|
+
}
|
|
3668
|
+
}));
|
|
3104
3669
|
ctx.tools.register(defineTool({
|
|
3105
3670
|
name: "crossfire_open",
|
|
3106
|
-
description: "Open a 针锋 (adversarial) review: put ONE settled result on the stand so red-team experts can attack it. Records the subject, your stance (optional but recommended — it gives the attack a target), the dimensions to attack from, and the
|
|
3671
|
+
description: "Open a 针锋 (adversarial) review: put ONE settled result on the stand so red-team experts can attack it. Records the subject, your stance (optional but recommended — it gives the attack a target), the dimensions to attack from, the intensity, and (when the user confirmed a settings card) the red-team roster. Returns a review_id in status \"draft\"; the review card appears in the trajectory. Preferred flow (v0.2.0): call crossfire_plan_review first and let the user confirm, then call this tool with exactly the confirmed values — including experts, which are stored on the record and consumed by crossfire_attack. Next step is crossfire_attack. Use this ONLY for something already decided — Crossfire never produces a plan and never fixes anything.",
|
|
3107
3672
|
parameters: {
|
|
3108
3673
|
title: {
|
|
3109
3674
|
type: "string",
|
|
@@ -3149,6 +3714,41 @@ function registerCrossfireTools(ctx, config) {
|
|
|
3149
3714
|
source_meeting: {
|
|
3150
3715
|
type: "string",
|
|
3151
3716
|
description: "Optional: the roundtable meeting id this review was handed over from."
|
|
3717
|
+
},
|
|
3718
|
+
experts: {
|
|
3719
|
+
type: "array",
|
|
3720
|
+
description: "The confirmed red-team roster from crossfire_plan_review (pass it verbatim). Stored on the record and consumed by crossfire_attack, so each expert keeps its dimension, model, role and prompt. Omit to keep the defaults (one expert per dimension, inheriting your route) — behavior is then byte-identical to before.",
|
|
3721
|
+
items: {
|
|
3722
|
+
type: "object",
|
|
3723
|
+
additionalProperties: false,
|
|
3724
|
+
properties: {
|
|
3725
|
+
key: {
|
|
3726
|
+
type: "string",
|
|
3727
|
+
description: "Expert key (default: the dimension name)."
|
|
3728
|
+
},
|
|
3729
|
+
dimension: {
|
|
3730
|
+
type: "string",
|
|
3731
|
+
required: true,
|
|
3732
|
+
description: "The one dimension this expert attacks."
|
|
3733
|
+
},
|
|
3734
|
+
role: {
|
|
3735
|
+
type: "string",
|
|
3736
|
+
description: "Short supplementary role (narrows expertise only)."
|
|
3737
|
+
},
|
|
3738
|
+
provider: {
|
|
3739
|
+
type: "string",
|
|
3740
|
+
description: "Optional model provider route."
|
|
3741
|
+
},
|
|
3742
|
+
model: {
|
|
3743
|
+
type: "string",
|
|
3744
|
+
description: "Optional model id."
|
|
3745
|
+
},
|
|
3746
|
+
prompt: {
|
|
3747
|
+
type: "string",
|
|
3748
|
+
description: "Free-text supplementary prompt (narrows expertise only — never relaxes the hard constraints)."
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3152
3752
|
}
|
|
3153
3753
|
},
|
|
3154
3754
|
output: {
|
|
@@ -3212,6 +3812,8 @@ function registerCrossfireTools(ctx, config) {
|
|
|
3212
3812
|
...sourceMeeting === "" ? {} : { sourceMeeting },
|
|
3213
3813
|
ownerSessionId: caller.id
|
|
3214
3814
|
});
|
|
3815
|
+
const expertRoster = rosterFromArguments(args.experts);
|
|
3816
|
+
if (expertRoster.length > 0) record.expertRoster = expertRoster;
|
|
3215
3817
|
await withCrossfireLock(reviewLockKey(stateRoot, reviewId), async () => {
|
|
3216
3818
|
await writeRecord(stateRoot, record);
|
|
3217
3819
|
});
|
|
@@ -3280,18 +3882,7 @@ function registerCrossfireTools(ctx, config) {
|
|
|
3280
3882
|
requireStatus(record, ["draft", "attacking"]);
|
|
3281
3883
|
const running = record.experts.filter((expert) => expert.status === "running");
|
|
3282
3884
|
if (running.length > 0) throw new Error(`experts are already running for review "${reviewId}" (${running.map((expert) => expert.key).join(", ")}) — wait for them to report, or collect what they have already submitted`);
|
|
3283
|
-
const
|
|
3284
|
-
const experts = [];
|
|
3285
|
-
for (const dimension of record.dimensions) {
|
|
3286
|
-
const count = Math.max(1, spec.expertsPerDimension);
|
|
3287
|
-
for (let index = 0; index < count; index += 1) experts.push({
|
|
3288
|
-
key: count === 1 ? dimension : `${dimension}-${index + 1}`,
|
|
3289
|
-
dimension,
|
|
3290
|
-
status: "pending",
|
|
3291
|
-
defectCount: 0
|
|
3292
|
-
});
|
|
3293
|
-
}
|
|
3294
|
-
const trimmed = experts.length > config.maxExperts ? experts.slice(0, config.maxExperts) : experts;
|
|
3885
|
+
const trimmed = planExperts(record, config.maxExperts, record.expertRoster ?? []);
|
|
3295
3886
|
if (record.reviewPass > 1 && record.history.length > 0 && record.defects.length === 0) record.defects = buildRecheckItems(record);
|
|
3296
3887
|
record.experts = trimmed;
|
|
3297
3888
|
record.status = "attacking";
|