minimal-agent 0.7.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.7.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(finalText);
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
- await state.cleanup();
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
- { role: 'system', content: useSchema ? LLM_STEP_SYSTEM_SCHEMA : LLM_STEP_SYSTEM_PLAIN },
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(trimmed);
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();
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
- const resp = await fetch(url, {
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(body),
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
- // 4. 错误处理:把 HTTP 错误尽量翻译成可读消息
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
- throw new Error(`LLM 请求失败 [${resp.status} ${resp.statusText}]:${errText.slice(0, 500)}`);
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 {
@@ -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
+ }
@@ -1,26 +1,40 @@
1
1
  # ============================================================
2
- # workflows/plan.yaml —— 复刻 Claude Code「plan」流程(Analyze→Verify→Synthesize→Critic→Gate)
2
+ # workflows/plan.yaml —— 复刻 Claude Code「plan」流程(Clarify→Analyze→Verify→Synthesize→Critic→Gate)
3
3
  # ------------------------------------------------------------
4
- # 把 Ultracode 动态工作流的多阶段编进确定性 DAG,全用现有 StepKind(不改引擎):
5
- # Analyze : analyze(agent 只读探索) → extract_findings(llm 固化成可验证 claims) → clarify_depth(AskUserQuestion 人机交互)
6
- # Verify : verify_findings(type:vote 红队对抗——对每条 finding 反向提问证伪,存活的才进下游)
7
- # Synthesize: draft_plan(agent 综合"通过验证的结论"+用户选择,写 ./PLAN.md)
8
- # Critic : critic_plan(skill:adversarial-review 反向提问审稿,把审稿意见追加进 ./PLAN.md)
9
- # Gate : verify(assert 守门,确认 ./PLAN.md 确实生成)
10
- # 非交互宿主(-p/gateway)下 AskUserQuestion 自动选推荐项、vote 走 LLM,整条流程照常跑完。
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 生成)
11
13
  # ------------------------------------------------------------
12
- # 注:critic 阶段用 skill: 节点 dogfood 仓库自带的 adversarial-review skill —— 演示
13
- # 「同一套对抗审查方法既能 standalone(/adversarial-review)用、也能在 workflow 里被复用」。
14
- # skill 双源查找(cwd 优先、packageRoot fallback);万一异地 cwd 缺该 skill,
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 时
15
30
  # critic 的 onError:continue 兜底——审稿出岔不应推翻已写好的计划。
16
31
  # ============================================================
17
32
  name: plan
18
33
  description: |
19
- 受工作流指导 + 红队对抗验证 + 反向提问审稿 + 人机交互的动态计划流程。
20
- Analyze(探索+固化+问深度) → Verify(vote 红队证伪候选结论) → Synthesize(综合通过验证的结论写 PLAN.md)
21
- → Critic(反向提问审稿,把意见追加进 PLAN.md) → Gate(assert 守门确认文件生成)。
22
- minimal-agent 的旗舰演示。
23
- version: "0.2"
34
+ 受工作流指导 + 红队对抗验证 + 反向提问审稿的动态计划流程(跨模型稳、开头一次性提问后全自主)。
35
+ Clarify(开头问深度) → Analyze(探索+固化) → Verify(vote 红队证伪) → Synthesize(出正文+落盘)
36
+ → Critic(反向提问审稿追加) → Gate(assert 守门)。minimal-agent 的旗舰演示。
37
+ version: "0.3"
24
38
 
25
39
  inputs:
26
40
  - name: goal
@@ -29,6 +43,26 @@ inputs:
29
43
  description: "要做的事 / 目标"
30
44
 
31
45
  steps:
46
+ # ===================== Clarify(开头唯一提问点)=====================
47
+ # [Clarify] 一次性问"计划深度"(这是真有取舍、无统一最优的点;第一项=推荐,非交互自动选它)。
48
+ # 放最前:问完之后全程自主。其它任务专属细节(受众/时长/范围)由 draft 自做合理默认并列出,
49
+ # 不再中途打断——契合"明确有优劣自动判断、只有真取舍才问"。
50
+ - id: clarify_depth
51
+ tool: AskUserQuestion
52
+ args:
53
+ questions:
54
+ - header: "计划深度"
55
+ question: "希望这份实现计划做到多详细?"
56
+ options:
57
+ - label: "详细分步 (推荐)"
58
+ description: "拆成可执行的有序步骤,每步说清动作、涉及文件与验收点"
59
+ - label: "快速概览"
60
+ description: "只给高层方向与关键决策,不展开到每一步"
61
+ - label: "带风险/回滚评估"
62
+ description: "在详细分步之外,额外标注每步风险与回滚方案"
63
+ capture:
64
+ content: depth_answer
65
+
32
66
  # ===================== Analyze =====================
33
67
  # [Analyze-1] 只读探索:agent 自主用 Read/Grep/Glob 摸清现状(不写盘、不副作用)
34
68
  - id: analyze
@@ -40,7 +74,7 @@ steps:
40
74
  capture:
41
75
  content: analysis
42
76
 
43
- # [Analyze-2] 固化成可验证 claims:llm 单轮 + output_schema,把笔记抽成数组(供 vote 逐条验证)
77
+ # [Analyze-2] 固化成可验证 claims:llm 单轮 + output_schema(provider 不支持时由 client.ts 自动退化)
44
78
  - id: extract_findings
45
79
  llm: |
46
80
  从下面的调研笔记中抽取「候选关键事实」与「未决问题」,原样保留信息、不要新增臆测。
@@ -63,27 +97,9 @@ steps:
63
97
  findings: findings
64
98
  open_questions: open_questions
65
99
 
66
- # [Analyze-3] 人机交互:直接用 AskUserQuestion 问「计划深度」(第一项=推荐,非交互自动选它)
67
- - id: clarify_depth
68
- tool: AskUserQuestion
69
- args:
70
- questions:
71
- - header: "计划深度"
72
- question: "希望这份实现计划做到多详细?"
73
- options:
74
- - label: "详细分步 (推荐)"
75
- description: "拆成可执行的有序步骤,每步说清动作、涉及文件与验收点"
76
- - label: "快速概览"
77
- description: "只给高层方向与关键决策,不展开到每一步"
78
- - label: "带风险/回滚评估"
79
- description: "在详细分步之外,额外标注每步风险与回滚方案"
80
- capture:
81
- content: depth_answer
82
-
83
100
  # ===================== Verify =====================
84
- # [Verify] 红队对抗:vote 节点对每条 finding 反向提问证伪(voter 默认倾向 refuted=true)
85
- # refuted 票数 >= threshold 的 finding 被驳回,只有存活的进下游 —— 杜绝未证实结论污染计划。
86
- # 注:voter 偏向证伪,存活集可能偏少甚至为空;下游 draft_plan 对此做了 graceful degrade(见其提示)。
101
+ # [Verify] 红队对抗:vote 对每条 finding 反向提问证伪(voter 默认倾向 refuted=true);
102
+ # 存活集可能偏少甚至为空,draft 对此 graceful degrade(见其提示)。
87
103
  - id: verify_findings
88
104
  type: vote
89
105
  input: "findings"
@@ -99,11 +115,12 @@ steps:
99
115
  total: total_count
100
116
 
101
117
  # ===================== Synthesize =====================
102
- # [Synthesize] 综合产出:agent 用"通过验证的结论"+用户选择落盘 ./PLAN.md,遇取舍可再问用户
118
+ # [Synthesize-1] 生成计划正文:llm 纯文本(不开工具、不提问)——确定性、跨模型稳,
119
+ # 彻底避开"agent 反复调 Write"的弱模型死循环。
103
120
  - id: draft_plan
104
- agent: |
105
- 综合下列输入,为目标「${inputs.goal}」写一份中文实现计划,用 Write 落到 ./PLAN.md
106
- (目录不存在先创建)。
121
+ llm: |
122
+ 综合下列输入,为目标「${inputs.goal}」写一份**中文实现计划的完整 markdown 正文**
123
+ (直接输出正文本身:不要寒暄、不要复述要求、不要用代码围栏包裹整篇)。
107
124
 
108
125
  —— 通过红队对抗验证的结论(${kept_count}/${total_count} 条存活)——
109
126
  ${verified_findings}
@@ -115,21 +132,32 @@ steps:
115
132
  ${depth_answer}
116
133
 
117
134
  要求:
118
- - 计划详细程度遵循用户上面的选择;
119
- - 关于上面"存活结论"为空的两种情形请分别处理:若原本就没抽出 finding,按目标从头调研;
120
- 若是被红队全部驳回(存活 0 但 total>0),别盲信被否的结论,用 Read/Grep/Glob 重新取证后再写;
121
- - 若遇方案取舍或关键信息模糊、继续做风险大,调 AskUserQuestion 让用户拍板
122
- (每题第一个选项放推荐并在 label 末尾标「(推荐)」),据回答继续;
123
- - 写完用 Read 自检 ./PLAN.md 内容确实落好了再收尾。
124
- allowed_tools: [Read, Grep, Glob, Write, AskUserQuestion]
135
+ - 详细程度遵循用户选择的深度;
136
+ - "存活结论"为空时不要硬编:按目标常识给出合理的计划骨架;
137
+ - 凡需按需求拍板、但本工作流未询问的点(如受众/时长/范围),**自行做合理默认假设并继续**,
138
+ 在正文开头用「## 假设(如需调整请告知)」一节列出假设,**绝不中断提问**。
125
139
  capture:
126
- content: plan_text
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}"
127
158
 
128
159
  # ===================== Critic =====================
129
- # [Critic] 反向提问审稿:dogfood 仓库自带的 adversarial-review skill —— 同一套对抗审查
130
- # 方法,既能 standalone(/adversarial-review)用,也能在 workflow 里被 skill: 复用。
131
- # 产出由 skill 子 agent 直接 Edit/Write 追加进 ./PLAN.md(不 capture——下游无人消费)。
132
- # onError:continue —— 审稿出岔(含异地 cwd 缺 skill)不应推翻已写好的计划。
160
+ # [Critic] 反向提问审稿:dogfood 仓库自带 adversarial-review skill;onError:continue 兜底
133
161
  - id: critic_plan
134
162
  skill: adversarial-review
135
163
  maxTurns: 25
@@ -145,4 +173,4 @@ steps:
145
173
  - id: verify
146
174
  type: assert
147
175
  condition: 'fileExists("./PLAN.md")'
148
- onFail: "计划文件 ./PLAN.md 未生成,检查 draft_plan 步是否成功用 Write 落盘"
176
+ onFail: "计划文件 ./PLAN.md 未生成,检查 draft_plan/write_plan 步是否成功"