minimal-agent 0.6.9 → 0.7.1
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/package.json +1 -1
- package/plugins/workflow-runner/src/jsonPayload.js +89 -0
- package/plugins/workflow-runner/src/miniReAct.js +16 -1
- package/plugins/workflow-runner/src/runner.js +10 -1
- package/plugins/workflow-runner/src/stepExecutors/llm.js +16 -3
- package/plugins/workflow-runner/src/stepExecutors/skill.js +6 -0
- package/skills/adversarial-review/SKILL.md +125 -0
- package/src/llm/client.js +71 -18
- package/src/ui/hooks/useChat.js +32 -0
- package/src/utils/debugLog.js +52 -0
- package/workflows/plan.yaml +136 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/workflow-runner/src/jsonPayload.ts —— 从 LLM 文本里提取纯 JSON 负载
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 根因(为什么需要这个文件):
|
|
6
|
+
* "思考型"模型(MiniMax-M2/M3、DeepSeek-R1 系等)即使在 strict json_schema 下,
|
|
7
|
+
* 仍会把 <think>…</think> 思维链**直接写进 content 字段**(不是 reasoning_content)。
|
|
8
|
+
* src/llm/client.ts 故意原样保留这段思维链(主循环下一轮工具调用要靠它接上下文,
|
|
9
|
+
* 见 client.ts:200-205 注释),所以"剥离思维链"不能放进通用 transport——否则会
|
|
10
|
+
* 破坏主循环。剥离必须发生在「契约要求纯 JSON」的边界,也就是 workflow 的
|
|
11
|
+
* output_schema 解析处。本模块就是那个边界的唯一实现,被两个解析点共享:
|
|
12
|
+
* - stepExecutors/llm.ts(llm + output_schema 单轮)
|
|
13
|
+
* - miniReAct.ts(agent 节点 / llm+context_files / onError:recover 三处子循环)
|
|
14
|
+
* 放一处、两边 import,杜绝"只修一个解析点、另一个继续炸"的漂移。
|
|
15
|
+
*
|
|
16
|
+
* 处理顺序(保守、纯函数、可单测):
|
|
17
|
+
* 1. 去掉所有 <think>…</think> 块(多行、可多个、大小写不敏感)。
|
|
18
|
+
* **必须先做**:思维链里常出现 { } [ ](模型在"想" JSON 长什么样),
|
|
19
|
+
* 若不先剥,下面"找首个 JSON 起点"会错锚到思维链里的括号。
|
|
20
|
+
* 2. 兜底:只有起始 <think>、收尾 </think> 被 length 截断 → 砍掉从 <think> 起的全部。
|
|
21
|
+
* 3. 从剩余文本定位首个 { 或 [,配平括号截取到对应收尾。
|
|
22
|
+
* 这一步天然容忍 ```json 代码围栏与 JSON 前后的散文(只取配平的那一段),
|
|
23
|
+
* 所以不需要单独的"去围栏"正则(也就避免了误伤 JSON 字符串里的反引号)。
|
|
24
|
+
* 4. 找不到任何 JSON 起点 → 原样返回 trim 后文本,交调用方 JSON.parse 报
|
|
25
|
+
* "honest error"(保留原文前若干字符便于排查)。
|
|
26
|
+
* ============================================================
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* 从可能掺杂思维链 / 代码围栏 / 前后散文的文本里提取最可能的 JSON 负载字符串。
|
|
30
|
+
* 不做 JSON.parse(解析与错误信息留给调用方,便于各自定制报错文案)。
|
|
31
|
+
*/
|
|
32
|
+
export function extractJsonPayload(raw) {
|
|
33
|
+
// 1. 去成对的思维链块
|
|
34
|
+
let s = raw.replace(/<think>[\s\S]*?<\/think>/gi, '');
|
|
35
|
+
// 2. 截断兜底:有起始无收尾(被 max tokens 截断)→ 丢弃从 <think> 起的尾巴
|
|
36
|
+
if (/<think>/i.test(s) && !/<\/think>/i.test(s)) {
|
|
37
|
+
s = s.slice(0, s.search(/<think>/i));
|
|
38
|
+
}
|
|
39
|
+
s = s.trim();
|
|
40
|
+
// 3. 定位首个 JSON 起点并配平截取
|
|
41
|
+
const start = firstJsonStart(s);
|
|
42
|
+
if (start === -1)
|
|
43
|
+
return s; // 没有 JSON:原样返回,交调用方报错
|
|
44
|
+
const end = matchBalanced(s, start);
|
|
45
|
+
return end === -1 ? s.slice(start) : s.slice(start, end + 1);
|
|
46
|
+
}
|
|
47
|
+
/** 首个 { 或 [ 的下标(取更靠前者);都没有返回 -1。 */
|
|
48
|
+
function firstJsonStart(s) {
|
|
49
|
+
const obj = s.indexOf('{');
|
|
50
|
+
const arr = s.indexOf('[');
|
|
51
|
+
if (obj === -1)
|
|
52
|
+
return arr;
|
|
53
|
+
if (arr === -1)
|
|
54
|
+
return obj;
|
|
55
|
+
return Math.min(obj, arr);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 从 start({ 或 [)配平到对应的 } 或 ],返回收尾下标;找不到返回 -1。
|
|
59
|
+
* 跳过字符串字面量内部的括号与转义,避免被 JSON 值里的 } / ] 误导。
|
|
60
|
+
*/
|
|
61
|
+
function matchBalanced(s, start) {
|
|
62
|
+
const open = s[start];
|
|
63
|
+
const close = open === '{' ? '}' : ']';
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let inStr = false;
|
|
66
|
+
let esc = false;
|
|
67
|
+
for (let i = start; i < s.length; i++) {
|
|
68
|
+
const c = s[i];
|
|
69
|
+
if (inStr) {
|
|
70
|
+
if (esc)
|
|
71
|
+
esc = false;
|
|
72
|
+
else if (c === '\\')
|
|
73
|
+
esc = true;
|
|
74
|
+
else if (c === '"')
|
|
75
|
+
inStr = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (c === '"')
|
|
79
|
+
inStr = true;
|
|
80
|
+
else if (c === open)
|
|
81
|
+
depth++;
|
|
82
|
+
else if (c === close) {
|
|
83
|
+
depth--;
|
|
84
|
+
if (depth === 0)
|
|
85
|
+
return i;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* ============================================================
|
|
32
32
|
*/
|
|
33
33
|
import { chat, executeTool, } from '../../../src/plugin-sdk.js';
|
|
34
|
+
import { extractJsonPayload } from './jsonPayload.js';
|
|
34
35
|
/**
|
|
35
36
|
* 执行一次 ReAct 子循环。**边跑边 yield UI 事件(stage_change / text / reasoning /
|
|
36
37
|
* tool_start / tool_end),同时 return 最终 StepResult**——让 recover / agent 节点 /
|
|
@@ -49,6 +50,18 @@ export async function* runMiniReAct(args) {
|
|
|
49
50
|
const maxTurns = args.maxTurns ?? 10;
|
|
50
51
|
// 工作消息列表:拷贝一份,避免污染调用方
|
|
51
52
|
const conv = [...messages];
|
|
53
|
+
// schema 模式:把 schema 写进 system(同 llm.ts)。保证 provider 不支持 response_format
|
|
54
|
+
// 而被 client.ts 退化掉时,模型仍知道确切字段结构、不至于瞎猜字段名。
|
|
55
|
+
if (output_schema) {
|
|
56
|
+
const hint = `\n\n最终只输出匹配以下 JSON Schema 的 JSON 对象本身(不要解释、不要 markdown 代码块):\n${JSON.stringify(output_schema)}`;
|
|
57
|
+
const sysIdx = conv.findIndex((m) => m.role === 'system');
|
|
58
|
+
if (sysIdx >= 0) {
|
|
59
|
+
conv[sysIdx] = { ...conv[sysIdx], content: `${conv[sysIdx].content ?? ''}${hint}` };
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
conv.unshift({ role: 'system', content: hint.trimStart() });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
let finalText = '';
|
|
53
66
|
/** 上一轮是否仍有 tool_calls(用于检测 "用尽轮数还在调工具" 这一退出态) */
|
|
54
67
|
let lastTurnHadToolCalls = false;
|
|
@@ -162,9 +175,11 @@ export async function* runMiniReAct(args) {
|
|
|
162
175
|
}
|
|
163
176
|
// ---- 5. 输出处理 ----
|
|
164
177
|
if (output_schema) {
|
|
178
|
+
// 同 llm.ts:先剥 <think> 思维链/围栏/前后散文再 parse(见 jsonPayload.ts 头注)。
|
|
179
|
+
const payload = extractJsonPayload(finalText);
|
|
165
180
|
let parsed;
|
|
166
181
|
try {
|
|
167
|
-
parsed = JSON.parse(
|
|
182
|
+
parsed = JSON.parse(payload);
|
|
168
183
|
}
|
|
169
184
|
catch (e) {
|
|
170
185
|
throw new Error(`miniReAct: output_schema 要求 JSON 但解析失败: ${e.message}\n收到: ${finalText.slice(0, 200)}`);
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* ============================================================
|
|
23
23
|
*/
|
|
24
24
|
import { getWorkingDir } from '../../../src/plugin-sdk.js';
|
|
25
|
+
import { dlog } from '../../../src/utils/debugLog.js';
|
|
25
26
|
import { evalExpr, interpolate } from './expressions.js';
|
|
26
27
|
import { runRecovery } from './recovery.js';
|
|
27
28
|
import { execAgentStep } from './stepExecutors/agent.js';
|
|
@@ -131,6 +132,7 @@ async function* runSteps(steps, vars, ctx, state) {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
const kind = stepKind(step);
|
|
135
|
+
dlog('wf', `step ${i + 1}/${steps.length} START ${step.id} (${kind})`);
|
|
134
136
|
yield {
|
|
135
137
|
type: 'workflow_step_start',
|
|
136
138
|
id: step.id,
|
|
@@ -509,6 +511,7 @@ export async function* runWorkflow(def, inputs, ctx) {
|
|
|
509
511
|
max: def.steps.length,
|
|
510
512
|
message: `▶ workflow ${def.name}`,
|
|
511
513
|
};
|
|
514
|
+
let succeeded = false;
|
|
512
515
|
try {
|
|
513
516
|
const halted = yield* runSteps(def.steps, vars, runCtx, state);
|
|
514
517
|
if (halted)
|
|
@@ -523,9 +526,15 @@ export async function* runWorkflow(def, inputs, ctx) {
|
|
|
523
526
|
message: `✓ workflow ${def.name} 完成`,
|
|
524
527
|
};
|
|
525
528
|
yield { type: 'workflow_done', name: def.name, vars: vars.snapshot() };
|
|
529
|
+
succeeded = true;
|
|
526
530
|
}
|
|
527
531
|
finally {
|
|
528
|
-
|
|
532
|
+
// 只在「干净跑完」时清现场;halt / 中断 / 抛错都**保留** .minimal-agent-workflow/
|
|
533
|
+
// (current.json / inputs.json / vars.json / progress.md,含失败那步的 ✗ 行)供事后排查
|
|
534
|
+
// ——这正是这些文件的设计目的(workflowState.ts 头注);失败时删掉等于自废武功。
|
|
535
|
+
// 残留不会永久堆积:下次同名 workflow 的 init() 会先 rm,/new 也按 .minimal-agent-* 前缀统一清。
|
|
536
|
+
if (succeeded)
|
|
537
|
+
await state.cleanup();
|
|
529
538
|
}
|
|
530
539
|
}
|
|
531
540
|
/**
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { ALL_TOOLS, chat, } from '../../../../src/plugin-sdk.js';
|
|
24
24
|
import { interpolate } from '../expressions.js';
|
|
25
|
+
import { extractJsonPayload } from '../jsonPayload.js';
|
|
25
26
|
import { runMiniReAct } from '../miniReAct.js';
|
|
26
27
|
const LLM_STEP_SYSTEM_PLAIN = '你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
|
|
27
28
|
const LLM_STEP_SYSTEM_SCHEMA = `你正在被一个 workflow 调用。
|
|
@@ -68,8 +69,17 @@ export async function* execLlmStep(step, vars, ctx) {
|
|
|
68
69
|
}
|
|
69
70
|
// ===== END NEW =====
|
|
70
71
|
const useSchema = step.output_schema !== undefined;
|
|
72
|
+
// schema 模式:把 schema 直接写进 system prompt。这样无论 provider 是否支持
|
|
73
|
+
// response_format.json_schema(火山 GLM 等不支持、会被 client.ts 退化掉 response_format),
|
|
74
|
+
// 模型都知道确切字段名与结构 —— 退化路径也能产出正确形状的 JSON,而非瞎猜字段。
|
|
75
|
+
const schemaHint = useSchema
|
|
76
|
+
? `\n\n最终只输出匹配以下 JSON Schema 的 JSON 对象本身(不要解释、不要 markdown 代码块):\n${JSON.stringify(step.output_schema)}`
|
|
77
|
+
: '';
|
|
71
78
|
const messages = [
|
|
72
|
-
{
|
|
79
|
+
{
|
|
80
|
+
role: 'system',
|
|
81
|
+
content: (useSchema ? LLM_STEP_SYSTEM_SCHEMA : LLM_STEP_SYSTEM_PLAIN) + schemaHint,
|
|
82
|
+
},
|
|
73
83
|
{ role: 'user', content: prompt },
|
|
74
84
|
];
|
|
75
85
|
const responseFormat = useSchema
|
|
@@ -103,10 +113,13 @@ export async function* execLlmStep(step, vars, ctx) {
|
|
|
103
113
|
}
|
|
104
114
|
const trimmed = text.trim();
|
|
105
115
|
if (useSchema) {
|
|
106
|
-
// strict structured output:解析 JSON 后整个对象作为 raw
|
|
116
|
+
// strict structured output:解析 JSON 后整个对象作为 raw。
|
|
117
|
+
// 思考型模型即便在 strict 下仍会把 <think>…</think> 写进 content(见 jsonPayload.ts
|
|
118
|
+
// 头注与 src/llm/client.ts:200),故先剥思维链/前后散文再 parse;报错仍带原文便于排查。
|
|
119
|
+
const payload = extractJsonPayload(text);
|
|
107
120
|
let parsed;
|
|
108
121
|
try {
|
|
109
|
-
parsed = JSON.parse(
|
|
122
|
+
parsed = JSON.parse(payload);
|
|
110
123
|
}
|
|
111
124
|
catch (e) {
|
|
112
125
|
throw new Error(`step ${step.id}: output_schema 要求 JSON 输出但解析失败: ${e.message}\n收到: ${trimmed.slice(0, 200)}`);
|
|
@@ -96,6 +96,12 @@ export async function* execSkillStep(step, vars, ctx) {
|
|
|
96
96
|
throw new Error('用户中断');
|
|
97
97
|
if (ev.type === 'error')
|
|
98
98
|
throw new Error(ev.error);
|
|
99
|
+
// 子 skill 内部那次 runQuery 的 turn_done 是它自己的终结标记,不是整个 workflow 的终结。
|
|
100
|
+
// 不外泄——否则把 turn_done 当终结的消费者(-p --output-format json / gateway broker)
|
|
101
|
+
// 会在 workflow 中途(如 critic 步之后、verify 步之前)误判结束、漏掉后续步骤。
|
|
102
|
+
// workflow 的对外收口由 index.ts::runWorkflowFromCommand 末尾统一补发。
|
|
103
|
+
if (ev.type === 'turn_done')
|
|
104
|
+
continue;
|
|
99
105
|
yield ev;
|
|
100
106
|
}
|
|
101
107
|
const trimmed = text.trim();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adversarial-review
|
|
3
|
+
description: 质疑一个结论 / 计划 / 既有评审 / 设计方案是否站得住脚——默认怀疑、逼出证据,把发现分成「真问题 / 误报 / 待澄清」。注意:若用户只是想给代码找冗余 / 反模式 / 做常规质量改进,用 /simplify 而非本 skill。
|
|
4
|
+
triggers: 对抗审查、反向提问、红队、挑战这个结论、验证某结论或方案是否站得住脚、哪些是误报哪些是真问题、审稿、复核这个判断
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /adversarial-review —— 对抗式审查(反向提问,默认怀疑)
|
|
8
|
+
|
|
9
|
+
## 目的
|
|
10
|
+
|
|
11
|
+
验证一个**结论 / 计划 / 既有评审 / 设计方案 / 一段代码**是否站得住脚时,别让模型(或你自己)
|
|
12
|
+
轻易点头。人和 LLM 面对「这个对吗?」都有**迎合反射**——倾向于附和、补论据、越夸越顺。
|
|
13
|
+
本 skill 的目的:用「反向提问」把这种迎合反射**翻转成怀疑反射**,再用举证纪律把怀疑落到实处,
|
|
14
|
+
最终把发现诚实地分成 🔴 真问题 / ⚪ 误报 / 🟡 待澄清。
|
|
15
|
+
|
|
16
|
+
## 方法一:把问题转成「表面反向的提问」(扳机)
|
|
17
|
+
|
|
18
|
+
**永远不要问「X 对不对 / X 是否正确」,要问「X 是不是错的 / X 哪里站不住脚」。**
|
|
19
|
+
|
|
20
|
+
为什么仅仅换个问法就有用?因为**举证责任被翻转**了:
|
|
21
|
+
|
|
22
|
+
- 问「X 对吗?」→ 回答者可以**零成本附和**("对的、挺好"),不费力就能交差,迎合反射全开。
|
|
23
|
+
- 问「X 错在哪?」→ 回答者**必须先假设它有错**,然后去枚举失败场景、找反例、回现场核对才能回答;
|
|
24
|
+
哪怕最后发现 X 其实成立,那也是**认真攻击过之后**的结论,而不是反射性点头。
|
|
25
|
+
|
|
26
|
+
类比面试:问「你会不会用 X?」对方反射答「会」;但问「你是不是其实不会 X?」对方被迫拿出
|
|
27
|
+
真凭实据来反驳你——被逼着深度思考、自证清白。对抗审查就是把**每一次判断**都塞进后一种问法里。
|
|
28
|
+
|
|
29
|
+
> ⚠️ 「换问法」只是扳机,不是终点。光把问法反过来,模型同样可能编出**看似合理却牵强**的
|
|
30
|
+
> 假问题来"完成证伪任务",那是在制造新的误报。所以扳机必须接上「方法二:举证纪律」。
|
|
31
|
+
|
|
32
|
+
## 方法二:举证纪律(引擎)
|
|
33
|
+
|
|
34
|
+
反向提问扣动扳机后,每条判断都要过这三关,否则不算数:
|
|
35
|
+
|
|
36
|
+
1. **拆成原子主张**:把审查对象切成一条条**能被单独证伪**的主张。整段"感觉对"没意义,可攻击的最小单元才有。
|
|
37
|
+
2. **举证责任在主张方**:每条主张默认**未被证实**,靠具体反例 / 现场核对 / 可查依据来支撑或推翻。判定一律附证据,不附证据的判定本身就是错的。
|
|
38
|
+
3. **去现场,不凭记忆**:涉及代码 / 事实的主张,一律 Read / Grep 核对原文。用脑内印象当证据是制造误报的头号来源。
|
|
39
|
+
|
|
40
|
+
**误报和漏报一样有害。** 把成立的主张硬说成问题,和放过真问题一样是审查失败——这与「默认怀疑」同等重要。
|
|
41
|
+
|
|
42
|
+
## 反向提问转换示例(按情况直接套用)
|
|
43
|
+
|
|
44
|
+
把用户原本(或心里)的「迎合式问法」翻成「反向式问法」,再走方法二的纪律:
|
|
45
|
+
|
|
46
|
+
| 你想确认的事(迎合式 ❌) | 反向提问的问法(✅ 套这个) |
|
|
47
|
+
|---|---|
|
|
48
|
+
| 这个方案对吗? | 这个方案**是不是错的**?它在什么输入 / 场景 / 规模下会崩?哪一步的假设最可能不成立? |
|
|
49
|
+
| 这份代码评审准吗? | 这份评审里**哪些是误报、哪些是真问题**?逐条去现场核对它说的代码是否真这样。 |
|
|
50
|
+
| 我刚写的这段代码没问题吧? | 这段代码**哪里有 bug**?给我一个能让它出错的具体输入 / 边界 / 并发时序。 |
|
|
51
|
+
| X 这个事实成立吗? | X **在什么情况下是假的**?反例是什么?它的依据经得起核对吗? |
|
|
52
|
+
| 该选方案 A 吗? | 选 A **会在什么场景下变成错误决定**?A 比 B 差在哪?A 的隐藏代价是什么? |
|
|
53
|
+
| 这个回答 / 总结完整吗? | 这个回答**漏了什么**?哪个边界 / 失败路径 / 反例没覆盖? |
|
|
54
|
+
| 这个重构安全吗? | 这个重构**会悄悄破坏什么**?哪些调用方 / 测试 / 契约依赖了被改掉的行为? |
|
|
55
|
+
| 文档 / 注释和代码一致吗? | 文档**哪一句和代码对不上**?逐句拿去和实现核对,找出已经过时(stale)的描述。 |
|
|
56
|
+
|
|
57
|
+
**worked example(评审复核——最常见场景)**
|
|
58
|
+
|
|
59
|
+
输入:「上一阶段的改动,代码评审说有以下 N 个问题,是不是误报?」
|
|
60
|
+
|
|
61
|
+
- ❌ 迎合式追问「这些问题对吗?」→ 模型容易逐条附和"是的这是问题",等于没审。
|
|
62
|
+
- ✅ 反向式追问:「这 N 条里**哪些是误报、哪些是真问题,为什么**?每条都 Read 对应文件去现场核对它
|
|
63
|
+
描述的代码是否属实;拿得出反例/实证的才标 🔴 真问题,只是'觉得没充分说明'但推不倒的标 🟡 待澄清,
|
|
64
|
+
确认其实成立的标 ⚪ 误报并给出它为何成立。」
|
|
65
|
+
- 效果:模型被迫**为每条结论举证**(无论判真还是判误报),迎合空间被挤掉——这正是本 skill 要的。
|
|
66
|
+
|
|
67
|
+
## Quick Reference
|
|
68
|
+
|
|
69
|
+
| 步骤 | 操作 |
|
|
70
|
+
|------|------|
|
|
71
|
+
| 1 | 用 Read 读取被审查对象(结论/计划/评审/设计/代码);必要时 Grep/Glob 取证,去现场核对而非凭记忆 |
|
|
72
|
+
| 2 | 把对象拆成一条条**独立、可被单独证伪**的原子主张 |
|
|
73
|
+
| 3 | 对每条问「哪里站不住脚」(见下方拷问清单),强迫找反例与证据 |
|
|
74
|
+
| 4 | 给每条分类:🔴 真问题 / ⚪ 误报 / 🟡 待澄清 |
|
|
75
|
+
| 5 | 每条都附**证据**与**为什么**(凭什么这么判,证据指向哪里);没证据就别下 🔴 结论 |
|
|
76
|
+
|
|
77
|
+
## 第 3 步:对每条主张的拷问清单
|
|
78
|
+
|
|
79
|
+
逐条追问,任意一条命中就标记下来:
|
|
80
|
+
|
|
81
|
+
- **证据不足?** —— 它的依据是什么?依据本身可靠吗,还是空口断言?
|
|
82
|
+
- **把假设当事实?** —— 哪些是已验证的事实,哪些只是"应该是这样"的假设被当成了前提?
|
|
83
|
+
- **想当然 / 跳步?** —— 推导链有没有缺环?"因为 A 所以 C"中间的 B 被默认了吗?
|
|
84
|
+
- **有反例?** —— 能不能举出一个让它崩掉的具体场景、输入、边界条件?
|
|
85
|
+
- **被现有代码/事实推翻?** —— 去 Grep/Read 核对,它描述的代码真长这样吗?实现真按它说的走吗?
|
|
86
|
+
|
|
87
|
+
### 证明不了 ≠ 错
|
|
88
|
+
|
|
89
|
+
一段文本里很多成立的主张本就不会附带完整证据——**省略不等于错误**。所以:
|
|
90
|
+
|
|
91
|
+
- 你能拿出反例 / 找到被现有代码推翻的实证 → 标 **🔴 真问题**。
|
|
92
|
+
- 你只是觉得它"没充分举证"、但也举不出反例 → 标 **🟡 待澄清**,并写明「缺哪个证据才能判定」,**不要**直接判 🔴。
|
|
93
|
+
|
|
94
|
+
把「主张错误」与「主张未充分举证」混为一谈,是这类审查最常见的误报来源。
|
|
95
|
+
|
|
96
|
+
## 输出格式
|
|
97
|
+
|
|
98
|
+
三类同等呈现,不预设哪类一定有内容(真问题为 0 条是合法且常见的结论):
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
## 对抗审查结果
|
|
102
|
+
|
|
103
|
+
### 🔴 真问题(拿得出反例/实证的)
|
|
104
|
+
1. [主张/位置] 这条为什么站不住脚 → 证据(文件:行 / 反例 / 事实)→ 影响
|
|
105
|
+
2. ...
|
|
106
|
+
(若无:「真问题:0 条——下方列出已尝试的攻击角度」)
|
|
107
|
+
|
|
108
|
+
### ⚪ 误报(曾被怀疑、但其实成立)
|
|
109
|
+
1. [主张/位置] 原审查/结论怀疑的点 → 为什么它其实成立 → 证据
|
|
110
|
+
2. ...
|
|
111
|
+
|
|
112
|
+
### 🟡 待澄清(未充分举证、但也推不倒)
|
|
113
|
+
1. [主张/位置] 缺什么证据才能判定 → 需要用户/作者补充什么
|
|
114
|
+
2. ...
|
|
115
|
+
|
|
116
|
+
### 结论
|
|
117
|
+
一句话定调:整体站得住脚 / 有 N 个真问题需修 / 关键前提待澄清。
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## 注意事项
|
|
121
|
+
|
|
122
|
+
- **误报和漏报一样有害**(已在核心段强调,这里重申落地)。不要为了"显得严格"就把成立的结论硬说成问题——驳回一条主张同样要给证据,凭感觉拍砖等于制造新的误报。
|
|
123
|
+
- **找不到真问题就如实说**。如果对象本身扎实、你认真攻击后确实推不倒它,就直说"未发现真问题",并**列出你尝试过的反驳角度**(找过哪些反例、核对过哪些代码、质疑过哪些假设),证明你是真的攻击过、而非偷懒放水。
|
|
124
|
+
- **去现场,别凭记忆**。涉及代码/事实的主张,一律 Read/Grep 核对原文,不要用脑内印象当证据。
|
|
125
|
+
- **边界**:本 skill 用于质疑「结论 / 计划 / 既有评审 / 设计是否站得住脚」。若用户只是想给代码找冗余 / 反模式 / 做常规质量改进,那是 /simplify 的活,不要套用这里的「默认有罪」框架。
|
package/src/llm/client.js
CHANGED
|
@@ -16,6 +16,30 @@
|
|
|
16
16
|
* 本文件不依赖任何 npm 包(fetch 是 Bun/Node 20+ 内置)。
|
|
17
17
|
* ============================================================
|
|
18
18
|
*/
|
|
19
|
+
import { dlog } from '../utils/debugLog.js';
|
|
20
|
+
/**
|
|
21
|
+
* 记住"这个 baseURL::model 不支持 response_format.json_schema",避免每次都先撞 400。
|
|
22
|
+
* 单进程内有效;每个 -p 子进程是全新进程 → 首个结构化调用吃一次 400 再退化,可接受。
|
|
23
|
+
*/
|
|
24
|
+
const rfUnsupported = new Set();
|
|
25
|
+
/** 调试日志用的请求自增号(vote 并发多 voter 时靠它在日志里区分是哪一路)。 */
|
|
26
|
+
let reqCounter = 0;
|
|
27
|
+
/**
|
|
28
|
+
* 判断一个 400 是否在抱怨 response_format(json_schema)不被支持。
|
|
29
|
+
* 各 provider 文案不一(火山 GLM:`json_schema is not supported by this model`),
|
|
30
|
+
* 故宽松匹配:"提到 response_format" 且 "出现 not support / unsupported / not valid / invalid"。
|
|
31
|
+
*/
|
|
32
|
+
function isResponseFormatUnsupported(status, errText) {
|
|
33
|
+
if (status !== 400)
|
|
34
|
+
return false;
|
|
35
|
+
const t = errText.toLowerCase();
|
|
36
|
+
if (!t.includes('response_format'))
|
|
37
|
+
return false;
|
|
38
|
+
return (t.includes('not support') || // 命中 "not supported"
|
|
39
|
+
t.includes('unsupported') ||
|
|
40
|
+
t.includes('not valid') ||
|
|
41
|
+
t.includes('invalid'));
|
|
42
|
+
}
|
|
19
43
|
/**
|
|
20
44
|
* 调用 LLM 并以流的形式 yield 事件。
|
|
21
45
|
*
|
|
@@ -61,38 +85,65 @@ export async function* chat(args) {
|
|
|
61
85
|
// 3. 构造请求体
|
|
62
86
|
// 逃生开关:极个别 provider 不认 stream_options 会 400,可用 MINIMAL_AGENT_DISABLE_USAGE=1 关掉。
|
|
63
87
|
const includeUsage = process.env.MINIMAL_AGENT_DISABLE_USAGE !== '1';
|
|
64
|
-
const body = {
|
|
65
|
-
model: provider.model,
|
|
66
|
-
messages: cleanedMessages,
|
|
67
|
-
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
68
|
-
stream: true,
|
|
69
|
-
// stream_options.include_usage:让 provider 在流末附带真实 prompt_tokens,
|
|
70
|
-
// 供上层自动压缩判断用;provider 不支持时 usagePromptTokens 保持 undefined,上层 fallback 本地估算。
|
|
71
|
-
...(includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
72
|
-
// tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
|
|
73
|
-
tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
|
|
74
|
-
// ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
|
|
75
|
-
response_format: responseFormat,
|
|
76
|
-
};
|
|
77
|
-
// 3. 发请求(fetch 在 Bun/Node 20+ 内置)
|
|
78
88
|
const url = `${provider.baseURL.replace(/\/$/, '')}/chat/completions`;
|
|
79
|
-
|
|
89
|
+
// response_format 能力探测:部分 OpenAI 兼容 provider(如火山 GLM 系)不支持
|
|
90
|
+
// response_format.type=json_schema,会回 400。已知不支持的 model 直接跳过,
|
|
91
|
+
// 撞到 400 则退化重试(去掉 response_format,靠 prompt 内嵌 schema + 上层 extractJsonPayload 容错解析)。
|
|
92
|
+
const rfKey = `${provider.baseURL}::${provider.model}`;
|
|
93
|
+
const wantResponseFormat = responseFormat !== undefined && !rfUnsupported.has(rfKey);
|
|
94
|
+
const postChat = (withResponseFormat) => fetch(url, {
|
|
80
95
|
method: 'POST',
|
|
81
96
|
headers: {
|
|
82
97
|
'Content-Type': 'application/json',
|
|
83
98
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
84
99
|
},
|
|
85
|
-
body: JSON.stringify(
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
model: provider.model,
|
|
102
|
+
messages: cleanedMessages,
|
|
103
|
+
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
104
|
+
stream: true,
|
|
105
|
+
// stream_options.include_usage:让 provider 在流末附带真实 prompt_tokens,
|
|
106
|
+
// 供上层自动压缩判断用;provider 不支持时 usagePromptTokens 保持 undefined,上层 fallback 本地估算。
|
|
107
|
+
...(includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
108
|
+
// tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
|
|
109
|
+
tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
|
|
110
|
+
// ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
|
|
111
|
+
response_format: withResponseFormat ? responseFormat : undefined,
|
|
112
|
+
}),
|
|
86
113
|
signal,
|
|
87
114
|
});
|
|
88
|
-
//
|
|
115
|
+
// 3. 发请求(fetch 在 Bun/Node 20+ 内置)
|
|
116
|
+
const reqId = ++reqCounter;
|
|
117
|
+
dlog('llm', `req#${reqId} POST →`, {
|
|
118
|
+
model: provider.model,
|
|
119
|
+
rf: wantResponseFormat,
|
|
120
|
+
msgs: cleanedMessages.length,
|
|
121
|
+
tools: openaiTools.length,
|
|
122
|
+
});
|
|
123
|
+
let resp = await postChat(wantResponseFormat);
|
|
124
|
+
// 4. 错误处理:先处理 "json_schema 不支持" 的退化重试,再翻译其它 HTTP 错误
|
|
89
125
|
if (!resp.ok) {
|
|
90
126
|
const errText = await resp.text().catch(() => '');
|
|
91
|
-
|
|
127
|
+
if (wantResponseFormat && isResponseFormatUnsupported(resp.status, errText)) {
|
|
128
|
+
// 记住该 model 不支持,单进程内后续结构化调用直接跳过 response_format(不再撞 400)。
|
|
129
|
+
rfUnsupported.add(rfKey);
|
|
130
|
+
console.warn(`[minimal-agent] provider model "${provider.model}" 不支持 response_format.json_schema,` +
|
|
131
|
+
`已退化为 prompt 引导 + 容错解析(结构化输出的硬保证降级为尽力而为)。`);
|
|
132
|
+
resp = await postChat(false);
|
|
133
|
+
if (!resp.ok) {
|
|
134
|
+
const e2 = await resp.text().catch(() => '');
|
|
135
|
+
throw new Error(`LLM 请求失败 [${resp.status} ${resp.statusText}]:${e2.slice(0, 500)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw new Error(`LLM 请求失败 [${resp.status} ${resp.statusText}]:${errText.slice(0, 500)}`);
|
|
140
|
+
}
|
|
92
141
|
}
|
|
93
142
|
if (!resp.body) {
|
|
94
143
|
throw new Error('LLM 响应没有 body(provider 可能不支持流式?)');
|
|
95
144
|
}
|
|
145
|
+
// fetch 已返回且 ok:若日志卡在这条之前 = fetch 阶段挂起(无超时);卡在这条之后无 done = 流中途挂起。
|
|
146
|
+
dlog('llm', `req#${reqId} streaming begin (status ${resp.status})`);
|
|
96
147
|
// 5. SSE 解析:把 ReadableStream 切成一行行 "data: ..." 然后 JSON.parse
|
|
97
148
|
const reader = resp.body.getReader();
|
|
98
149
|
const decoder = new TextDecoder();
|
|
@@ -129,6 +180,7 @@ export async function* chat(args) {
|
|
|
129
180
|
const dataStr = line.slice(5).trim(); // 去掉 "data:" 前缀
|
|
130
181
|
if (dataStr === '[DONE]') {
|
|
131
182
|
// 流结束标记;带出真实 prompt_tokens(provider 不返回时为 undefined,上层 fallback 本地估算)
|
|
183
|
+
dlog('llm', `req#${reqId} done [DONE]`, { stopReason, promptTokens: usagePromptTokens });
|
|
132
184
|
yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
|
|
133
185
|
return;
|
|
134
186
|
}
|
|
@@ -203,6 +255,7 @@ export async function* chat(args) {
|
|
|
203
255
|
}
|
|
204
256
|
}
|
|
205
257
|
// 如果流没显式发 [DONE],也补一个 done;同样带出 promptTokens
|
|
258
|
+
dlog('llm', `req#${reqId} done (stream end, no [DONE])`, { stopReason, promptTokens: usagePromptTokens });
|
|
206
259
|
yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
|
|
207
260
|
}
|
|
208
261
|
finally {
|
package/src/ui/hooks/useChat.js
CHANGED
|
@@ -26,6 +26,7 @@ import { ALL_TOOLS } from '../../tools/index.js';
|
|
|
26
26
|
import { setAskHandler, } from '../../askBridge.js';
|
|
27
27
|
import { clearFileState } from '../../tools/shared/fileState.js';
|
|
28
28
|
import { runWithPlugins } from '../../plugins/pluginRunner.js';
|
|
29
|
+
import { dlog } from '../../utils/debugLog.js';
|
|
29
30
|
/**
|
|
30
31
|
* reasoningPreview 环形 buffer 容量(字符)。
|
|
31
32
|
* LiveArea 现在多行显示思维链尾部(最近 N 行),需要喂饱足够文本;
|
|
@@ -201,6 +202,10 @@ export function useChat(args) {
|
|
|
201
202
|
return new Promise((resolve, reject) => {
|
|
202
203
|
askResolverRef.current?.reject(new Error('被新的提问取代'));
|
|
203
204
|
askResolverRef.current = { resolve, reject };
|
|
205
|
+
dlog('ui', 'ask handler INVOKED (等待用户答题)', {
|
|
206
|
+
n: questions.length,
|
|
207
|
+
headers: questions.map((q) => q.header),
|
|
208
|
+
});
|
|
204
209
|
setPendingAsk(questions);
|
|
205
210
|
const onAbort = () => {
|
|
206
211
|
if (!askResolverRef.current)
|
|
@@ -223,6 +228,7 @@ export function useChat(args) {
|
|
|
223
228
|
const r = askResolverRef.current;
|
|
224
229
|
askResolverRef.current = null;
|
|
225
230
|
setPendingAsk(null);
|
|
231
|
+
dlog('ui', 'ask ANSWERED (继续执行)', { selected: answers.map((a) => a.selected) });
|
|
226
232
|
r?.resolve(answers);
|
|
227
233
|
}, []);
|
|
228
234
|
// mount 时异步读 active session(默认 'default',注册表里有就用注册值)。
|
|
@@ -267,6 +273,7 @@ export function useChat(args) {
|
|
|
267
273
|
setRunningToolsMap(new Map());
|
|
268
274
|
const ac = new AbortController();
|
|
269
275
|
abortRef.current = ac;
|
|
276
|
+
dlog('ui', 'turn START', { input: trimmed.slice(0, 80) });
|
|
270
277
|
try {
|
|
271
278
|
for await (const ev of runWithPlugins(trimmed, {
|
|
272
279
|
provider: args.provider,
|
|
@@ -293,13 +300,17 @@ export function useChat(args) {
|
|
|
293
300
|
scheduleToolBump: toolBumpFlush.schedule,
|
|
294
301
|
cancelToolBump: toolBumpFlush.cancel,
|
|
295
302
|
bump,
|
|
303
|
+
// 工作流进度逐步可见(持久暗色行)
|
|
304
|
+
emitProgressLine: ephemeralLine,
|
|
296
305
|
});
|
|
297
306
|
}
|
|
298
307
|
}
|
|
299
308
|
catch (e) {
|
|
300
309
|
setError(`未捕获异常:${e.message}`);
|
|
310
|
+
dlog('ui', 'turn THREW', { err: e.message });
|
|
301
311
|
}
|
|
302
312
|
finally {
|
|
313
|
+
dlog('ui', 'turn END (finally)');
|
|
303
314
|
setIsLoading(false);
|
|
304
315
|
cancelAllThrottles();
|
|
305
316
|
resetTurnState();
|
|
@@ -514,6 +525,27 @@ export function useChat(args) {
|
|
|
514
525
|
};
|
|
515
526
|
}
|
|
516
527
|
export function handleEvent(ev, setters) {
|
|
528
|
+
// ---- 工作流进度事件(WorkflowEvent,不在 LoopEvent union 里)----
|
|
529
|
+
// 渲染成持久的暗色行落进 scrollback:跑到哪一步、成功/失败/跳过都看得见,
|
|
530
|
+
// 且出错后仍停留在屏幕上 —— 解决"工作流执行过程不可见、不知道停在哪"。
|
|
531
|
+
const wf = ev;
|
|
532
|
+
switch (wf.type) {
|
|
533
|
+
case 'workflow_start':
|
|
534
|
+
setters.emitProgressLine?.(`▶ 工作流 ${wf.name} 开始(共 ${wf.totalSteps} 步)`);
|
|
535
|
+
return;
|
|
536
|
+
case 'workflow_step_start':
|
|
537
|
+
setters.emitProgressLine?.(` ▶ ${(wf.index ?? 0) + 1}/${wf.total ?? '?'} ${wf.id} (${wf.kind})`);
|
|
538
|
+
return;
|
|
539
|
+
case 'workflow_step_end':
|
|
540
|
+
setters.emitProgressLine?.(` ${wf.ok ? '✓' : '✗'} ${wf.id}${wf.error ? `:${wf.error}` : ''}`);
|
|
541
|
+
return;
|
|
542
|
+
case 'workflow_step_skipped':
|
|
543
|
+
setters.emitProgressLine?.(` ⊘ ${wf.id}:${wf.reason ?? ''}`);
|
|
544
|
+
return;
|
|
545
|
+
case 'workflow_done':
|
|
546
|
+
setters.emitProgressLine?.(`✓ 工作流 ${wf.name} 完成`);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
517
549
|
// 收口到 LoopEvent:未识别的 PluginEvent type 走 default no-op
|
|
518
550
|
const e = ev;
|
|
519
551
|
switch (e.type) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/utils/debugLog.ts —— 文件调试日志(环境变量开关,默认全程 no-op)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 为什么需要:TUI(Ink)独占 stdout/stderr,console.log 在 TUI 里看不见、还会搅乱画面;
|
|
6
|
+
* 而"卡死/不响应"这类问题恰恰无法靠正常退出来事后检查。把关键生命周期点**同步追加**到
|
|
7
|
+
* 一个磁盘文件,进程即便随后挂死,日志也已落盘——文件的最后一行就指向卡住的位置。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* MINIMAL_AGENT_DEBUG_LOG=D:\tmp\ma-debug.log bun run start
|
|
11
|
+
* 然后复现问题,把该文件最后几十行发来即可定位。
|
|
12
|
+
*
|
|
13
|
+
* 设计:
|
|
14
|
+
* - 未设环境变量 → dlog() 直接 return,零开销、零行为改变(不影响 npm/dev/绿色版任何路径)。
|
|
15
|
+
* - 用 appendFileSync(同步):宁可慢一点也要保证"挂死前那一行确实写进了磁盘"。
|
|
16
|
+
* - 任何写日志的异常都吞掉,绝不让"记日志"反过来影响主流程。
|
|
17
|
+
* ============================================================
|
|
18
|
+
*/
|
|
19
|
+
import { appendFileSync } from 'node:fs';
|
|
20
|
+
const LOG_PATH = process.env.MINIMAL_AGENT_DEBUG_LOG;
|
|
21
|
+
/** 调试日志是否开启(供调用方决定要不要算开销较大的 data)。 */
|
|
22
|
+
export function isDebugLogOn() {
|
|
23
|
+
return !!LOG_PATH;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 追加一行调试日志:`<ISO 时间> [tag] msg {data}`。
|
|
27
|
+
* 未开启(无 MINIMAL_AGENT_DEBUG_LOG)时立即返回。
|
|
28
|
+
*/
|
|
29
|
+
export function dlog(tag, msg, data) {
|
|
30
|
+
if (!LOG_PATH)
|
|
31
|
+
return;
|
|
32
|
+
try {
|
|
33
|
+
const ts = new Date().toISOString();
|
|
34
|
+
const line = data !== undefined
|
|
35
|
+
? `${ts} [${tag}] ${msg} ${safeJson(data)}\n`
|
|
36
|
+
: `${ts} [${tag}] ${msg}\n`;
|
|
37
|
+
appendFileSync(LOG_PATH, line);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// 记日志失败绝不影响主流程
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 安全 JSON:循环引用 / 超长都不抛、不撑爆日志。 */
|
|
44
|
+
function safeJson(v) {
|
|
45
|
+
try {
|
|
46
|
+
const s = JSON.stringify(v);
|
|
47
|
+
return s.length > 600 ? `${s.slice(0, 600)}…` : s;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return String(v);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/workflows/plan.yaml
CHANGED
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
# ============================================================
|
|
2
|
-
# workflows/plan.yaml —— 复刻 Claude Code「plan
|
|
2
|
+
# workflows/plan.yaml —— 复刻 Claude Code「plan」流程(Clarify→Analyze→Verify→Synthesize→Critic→Gate)
|
|
3
3
|
# ------------------------------------------------------------
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
4
|
+
# 设计目标:① 跨基座模型稳(弱模型也能跑完,不依赖强 agent 多轮工具循环);
|
|
5
|
+
# ② 提问只在「开头一次性」发生,之后全自主无打断,直到产出可提交的 PLAN.md。
|
|
6
|
+
# 全用现有 StepKind(不改引擎):
|
|
7
|
+
# Clarify : clarify_depth(AskUserQuestion 开头一次性问深度——全流程唯一的人机交互点)
|
|
8
|
+
# Analyze : analyze(agent 只读探索) → extract_findings(llm+schema 固化成可验证 claims)
|
|
9
|
+
# Verify : verify_findings(type:vote 红队对每条 finding 反向提问证伪,存活的才进下游)
|
|
10
|
+
# Synthesize: draft_plan(llm 纯文本出计划正文) → read_existing_plan + write_plan(tool 确定性落盘)
|
|
11
|
+
# Critic : critic_plan(skill:adversarial-review 反向提问审稿,追加进 PLAN.md)
|
|
12
|
+
# Gate : verify(assert 守门确认 PLAN.md 生成)
|
|
13
|
+
# ------------------------------------------------------------
|
|
14
|
+
# 模型兼容性要点(为什么这样切分——上一版在火山 GLM-5.1 上两处翻车的根治):
|
|
15
|
+
# 1. 写盘用 "llm 出正文 + tool:Write 落盘" 而非 agent。原 agent+Write 写法在弱模型上
|
|
16
|
+
# 会反复调 Write 触发 miniReAct 重复检测 halt;真因还叠加 Write 的"覆盖前须先 Read"
|
|
17
|
+
# 守卫——PLAN.md 已存在时首个 Write 被拒、模型重试同参 Write→3 次→halt。
|
|
18
|
+
# 故 write 前置一个 tool:Read(onError:continue,首跑文件不存在则略过)满足守卫。
|
|
19
|
+
# 2. 结构化输出(extract_findings / vote)由 client.ts 自动退化:provider 不支持
|
|
20
|
+
# response_format.json_schema(火山 GLM)时去掉它、靠 prompt 内嵌 schema + 容错解析。
|
|
21
|
+
# 3. 仍是 agent 的只剩 analyze(只读探索)与 critic(审稿,onError:continue 兜底),
|
|
22
|
+
# 它们在弱模型上最坏只是 halt/跳过,不破坏已产出的计划。
|
|
23
|
+
# ------------------------------------------------------------
|
|
24
|
+
# 提问节奏:唯一提问点 clarify_depth 放最前——问完之后全程自主无打断。任务专属、
|
|
25
|
+
# 本工作流没问的点(受众/时长/范围等)由 draft 自做合理默认假设并在正文开头列出,
|
|
26
|
+
# 绝不中途打断(契合"明确有优劣自动判断、只有真取舍才问、开头问完即自主")。
|
|
27
|
+
# ------------------------------------------------------------
|
|
28
|
+
# 注:critic 用 skill: 节点 dogfood 仓库自带的 adversarial-review skill。
|
|
29
|
+
# skill 双源查找(cwd 优先、packageRoot fallback);异地 cwd 缺该 skill 时
|
|
30
|
+
# critic 的 onError:continue 兜底——审稿出岔不应推翻已写好的计划。
|
|
14
31
|
# ============================================================
|
|
15
32
|
name: plan
|
|
16
33
|
description: |
|
|
17
|
-
受工作流指导 +
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
遇到取舍 / 模糊点可再调 AskUserQuestion 让用户拍板;
|
|
22
|
-
- 最后 assert 守门,确认 PLAN.md 确实生成。
|
|
23
|
-
这是 minimal-agent 的旗舰演示:把「探索→澄清→(带推荐的)选择→产出计划」
|
|
24
|
-
编成确定性骨架,每个 agent 节点内是有界 TAOR,交互骑在 tool_call 回路上。
|
|
25
|
-
version: "0.1"
|
|
34
|
+
受工作流指导 + 红队对抗验证 + 反向提问审稿的动态计划流程(跨模型稳、开头一次性提问后全自主)。
|
|
35
|
+
Clarify(开头问深度) → Analyze(探索+固化) → Verify(vote 红队证伪) → Synthesize(出正文+落盘)
|
|
36
|
+
→ Critic(反向提问审稿追加) → Gate(assert 守门)。minimal-agent 的旗舰演示。
|
|
37
|
+
version: "0.3"
|
|
26
38
|
|
|
27
39
|
inputs:
|
|
28
40
|
- name: goal
|
|
@@ -31,19 +43,10 @@ inputs:
|
|
|
31
43
|
description: "要做的事 / 目标"
|
|
32
44
|
|
|
33
45
|
steps:
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(不要写文件、不要执行副作用命令)摸清现状,梳理出 3-6 条关键事实
|
|
39
|
-
以及 1-3 个仍然未决 / 需要用户拍板的点。
|
|
40
|
-
用简洁的中文 markdown 列点整理后直接作为你的最终回答返回,供后续步骤参考。
|
|
41
|
-
allowed_tools: [Read, Grep, Glob]
|
|
42
|
-
capture:
|
|
43
|
-
content: findings
|
|
44
|
-
|
|
45
|
-
# 2. 澄清:直接用 AskUserQuestion 工具问用户「计划深度」(演示 tool 节点交互)
|
|
46
|
-
# 第一个选项是推荐项(label 末尾标「(推荐)」),非交互宿主会自动选它。
|
|
46
|
+
# ===================== Clarify(开头唯一提问点)=====================
|
|
47
|
+
# [Clarify] 一次性问"计划深度"(这是真有取舍、无统一最优的点;第一项=推荐,非交互自动选它)。
|
|
48
|
+
# 放最前:问完之后全程自主。其它任务专属细节(受众/时长/范围)由 draft 自做合理默认并列出,
|
|
49
|
+
# 不再中途打断——契合"明确有优劣自动判断、只有真取舍才问"。
|
|
47
50
|
- id: clarify_depth
|
|
48
51
|
tool: AskUserQuestion
|
|
49
52
|
args:
|
|
@@ -60,31 +63,114 @@ steps:
|
|
|
60
63
|
capture:
|
|
61
64
|
content: depth_answer
|
|
62
65
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
- id:
|
|
66
|
+
# ===================== Analyze =====================
|
|
67
|
+
# [Analyze-1] 只读探索:agent 自主用 Read/Grep/Glob 摸清现状(不写盘、不副作用)
|
|
68
|
+
- id: analyze
|
|
66
69
|
agent: |
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
只读调研目标「${inputs.goal}」相关的代码 / 资料:用 Read、Grep、Glob 工具
|
|
71
|
+
(不要写文件、不要执行副作用命令)摸清现状,整理出 3-6 条候选关键事实
|
|
72
|
+
以及 1-3 个仍未决 / 需用户拍板的点,用简洁中文 markdown 列点返回。
|
|
73
|
+
allowed_tools: [Read, Grep, Glob]
|
|
74
|
+
capture:
|
|
75
|
+
content: analysis
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
# [Analyze-2] 固化成可验证 claims:llm 单轮 + output_schema(provider 不支持时由 client.ts 自动退化)
|
|
78
|
+
- id: extract_findings
|
|
79
|
+
llm: |
|
|
80
|
+
从下面的调研笔记中抽取「候选关键事实」与「未决问题」,原样保留信息、不要新增臆测。
|
|
81
|
+
调研笔记:
|
|
82
|
+
${analysis}
|
|
83
|
+
output_schema:
|
|
84
|
+
type: object
|
|
85
|
+
additionalProperties: false
|
|
86
|
+
required: [findings, open_questions]
|
|
87
|
+
properties:
|
|
88
|
+
findings:
|
|
89
|
+
type: array
|
|
90
|
+
items: { type: string }
|
|
91
|
+
description: "每条独立成句的候选关键事实(便于逐条对抗验证)"
|
|
92
|
+
open_questions:
|
|
93
|
+
type: array
|
|
94
|
+
items: { type: string }
|
|
95
|
+
description: "仍需进一步验证 / 用户拍板的点"
|
|
96
|
+
capture:
|
|
97
|
+
findings: findings
|
|
98
|
+
open_questions: open_questions
|
|
99
|
+
|
|
100
|
+
# ===================== Verify =====================
|
|
101
|
+
# [Verify] 红队对抗:vote 对每条 finding 反向提问证伪(voter 默认倾向 refuted=true);
|
|
102
|
+
# 存活集可能偏少甚至为空,draft 对此 graceful degrade(见其提示)。
|
|
103
|
+
- id: verify_findings
|
|
104
|
+
type: vote
|
|
105
|
+
input: "findings"
|
|
106
|
+
voters_count: 3
|
|
107
|
+
threshold: 2
|
|
108
|
+
rules: |
|
|
109
|
+
判断这条「调研结论」是否站得住脚。不要问"它对不对",要问"它哪里是错的":
|
|
110
|
+
是否证据不足、把假设当事实、想当然、会被反例或现有代码/资料推翻。
|
|
111
|
+
只有当你找不到任何合理反驳、且它确有依据时,才认定成立(refuted=false)。
|
|
112
|
+
capture:
|
|
113
|
+
survived: verified_findings
|
|
114
|
+
kept: kept_count
|
|
115
|
+
total: total_count
|
|
116
|
+
|
|
117
|
+
# ===================== Synthesize =====================
|
|
118
|
+
# [Synthesize-1] 生成计划正文:llm 纯文本(不开工具、不提问)——确定性、跨模型稳,
|
|
119
|
+
# 彻底避开"agent 反复调 Write"的弱模型死循环。
|
|
120
|
+
- id: draft_plan
|
|
121
|
+
llm: |
|
|
122
|
+
综合下列输入,为目标「${inputs.goal}」写一份**中文实现计划的完整 markdown 正文**
|
|
123
|
+
(直接输出正文本身:不要寒暄、不要复述要求、不要用代码围栏包裹整篇)。
|
|
124
|
+
|
|
125
|
+
—— 通过红队对抗验证的结论(${kept_count}/${total_count} 条存活)——
|
|
126
|
+
${verified_findings}
|
|
72
127
|
|
|
73
|
-
——
|
|
128
|
+
—— 仍未决的问题 ——
|
|
129
|
+
${open_questions}
|
|
130
|
+
|
|
131
|
+
—— 用户选择的计划深度 ——
|
|
74
132
|
${depth_answer}
|
|
75
133
|
|
|
76
134
|
要求:
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- 写完后用 Read 自检 ./PLAN.md 内容确实落好了再收尾。
|
|
82
|
-
allowed_tools: [Read, Grep, Glob, Write, AskUserQuestion]
|
|
135
|
+
- 详细程度遵循用户选择的深度;
|
|
136
|
+
- "存活结论"为空时不要硬编:按目标常识给出合理的计划骨架;
|
|
137
|
+
- 凡需按需求拍板、但本工作流未询问的点(如受众/时长/范围),**自行做合理默认假设并继续**,
|
|
138
|
+
在正文开头用「## 假设(如需调整请告知)」一节列出假设,**绝不中断提问**。
|
|
83
139
|
capture:
|
|
84
|
-
content
|
|
140
|
+
# llm 步(无 output_schema)的 raw 只有 {text,result},没有 content(那是 tool 步的字段)。
|
|
141
|
+
# 用错字段会 capture 到 undefined → 下游 Write 写 0 字节(已被端到端测试抓到)。
|
|
142
|
+
text: plan_text
|
|
143
|
+
|
|
144
|
+
# [Synthesize-2] 满足 Write 的"覆盖前须先 Read"守卫:PLAN.md 已存在则先 Read;
|
|
145
|
+
# 首跑文件不存在 → Read 报错 → onError:continue 略过(不影响后续 Write 新建)。
|
|
146
|
+
- id: read_existing_plan
|
|
147
|
+
tool: Read
|
|
148
|
+
args:
|
|
149
|
+
file_path: ./PLAN.md
|
|
150
|
+
onError: continue
|
|
151
|
+
|
|
152
|
+
# [Synthesize-3] 落盘:tool:Write 确定性写入(不经 agent 循环,弱模型也稳)
|
|
153
|
+
- id: write_plan
|
|
154
|
+
tool: Write
|
|
155
|
+
args:
|
|
156
|
+
file_path: ./PLAN.md
|
|
157
|
+
content: "${plan_text}"
|
|
158
|
+
|
|
159
|
+
# ===================== Critic =====================
|
|
160
|
+
# [Critic] 反向提问审稿:dogfood 仓库自带 adversarial-review skill;onError:continue 兜底
|
|
161
|
+
- id: critic_plan
|
|
162
|
+
skill: adversarial-review
|
|
163
|
+
maxTurns: 25
|
|
164
|
+
input: |
|
|
165
|
+
对当前目录下的 ./PLAN.md 做一次对抗审查(先用 Read 读它)。审完用 Edit / Write 在文件
|
|
166
|
+
末尾追加一节 "## 审稿意见(反向提问对抗审查)",按 skill 的输出格式分
|
|
167
|
+
「🔴 真问题 / ⚪ 误报 / 🟡 待澄清」三类列出并附结论(真问题为 0 条也如实写)。
|
|
168
|
+
只新增这一节,不要重写或删改计划正文。
|
|
169
|
+
onError: continue
|
|
85
170
|
|
|
86
|
-
#
|
|
171
|
+
# ===================== Gate =====================
|
|
172
|
+
# [Gate] 守门:确认计划文件确实生成
|
|
87
173
|
- id: verify
|
|
88
174
|
type: assert
|
|
89
175
|
condition: 'fileExists("./PLAN.md")'
|
|
90
|
-
onFail: "计划文件 ./PLAN.md 未生成,检查 draft_plan
|
|
176
|
+
onFail: "计划文件 ./PLAN.md 未生成,检查 draft_plan/write_plan 步是否成功"
|