minimal-agent 0.6.5 → 0.6.6

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.
@@ -0,0 +1,77 @@
1
+ # MINIMAL-TRACE —— 用最短路径读懂 agent 主循环
2
+
3
+ > 教学导读。本项目首要目标是「讲透一个 agent 从输入 → 工具循环 → 落盘」。
4
+ > 但 `src/loop.ts` 已含不少**生产硬化**(4 种压缩 / 反应式自救 / 并行事件队列 / 截断警告…),
5
+ > 初学者直接读会被淹没。这篇带你**第一遍只看主干**,把硬化层全部圈出来跳过。
6
+
7
+ ## 一句话本质
8
+
9
+ ```
10
+ 用户输入 → 调 LLM → LLM 想调工具就执行 → 结果喂回 → 再调 LLM → … → 不调工具了,输出最终回答
11
+ ```
12
+
13
+ 整个 agent 就是这个循环。它写在 `src/loop.ts::runQuery`(一个 `async function*`,逐步 `yield` 事件给 UI)。
14
+
15
+ ## 最小可读路径(第一遍只看这 6 步)
16
+
17
+ 打开 `src/loop.ts`,找到 `runQuery`,顺着看这几段——**只看主干,遇到下面「进阶层」列出的东西先跳过**:
18
+
19
+ 1. **用户消息入栈**:把 `{ role:'user', content: userInput }` push 进 `history`,`yield` 一个 `user_message_committed` 让 UI 先把它显示出来。`history` 是一个会被原地 mutate 的数组(调用方和 loop 共享同一引用)。
20
+
21
+ 2. **进入 `while (turn < maxTurns)`**:每一轮 = 一次「调 LLM(可能带工具)」。`maxTurns`(默认 50)是失控保护。
22
+
23
+ 3. **调 LLM、把流式响应拼成一条 assistant 消息**:`for await (const ev of chat({...}))` 逐块消费——`text_delta` 累积成答案文本并 `yield {type:'text'}`;`tool_call_delta` 累积成工具调用(OpenAI 协议下工具调用也是分片返回的,用 `mergeToolCallDelta` 合并)。循环结束后组装出 `assistantMsg` 推入 `history`、`yield assistant_message`。
24
+
25
+ 4. **没有工具调用 → 这一轮就是最终回答,结束**:`if (toolCallsByIndex.length === 0)` → `yield {type:'turn_done'}` 然后 `return`。这是循环的正常出口。
26
+
27
+ 5. **有工具调用 → 逐个执行、把结果按顺序回填 `history`**:对每个工具调用,`yield tool_start` → 调 `executeToolFn(name, argsJson, signal)` → `yield tool_end`,再把结果作为 `{ role:'tool', tool_call_id }` 消息 push 回 `history`。**关键**:tool 消息必须严格按 assistant 的 `tool_calls` 顺序回填(OpenAI 协议要求一一对应)。
28
+
29
+ 6. **回到 `while` 顶部继续下一轮**:模型这次能看到上一轮的工具结果,于是接着推理——要么再调工具,要么给出最终答案(回到第 4 步出口)。
30
+
31
+ 读完这 6 步,你就掌握了 agent 的全部本质。下面的都是「让它在生产环境不出岔子」的加固,第一遍可以完全不看。
32
+
33
+ ## 进阶层(第一遍请跳过)
34
+
35
+ 这些都是 `runQuery` 里为了**健壮/体验**加的料,每个解决一个具体问题,但都不影响你理解主循环:
36
+
37
+ | 你会看到 | 它解决什么 | 第一遍 |
38
+ |---|---|---|
39
+ | `autoCompactIfNeeded`(每轮开头) | 上下文快撑爆时,把旧对话压成摘要,防止超 contextWindow | 跳过 |
40
+ | `reactiveCompactIfApplicable`(catch 里) | 真撞上 prompt-too-long 时,压缩历史后**同轮重发**自救 | 跳过 |
41
+ | `snipCompactIfNeeded`(压缩失败兜底) | 连压缩都失败时,硬砍最老消息释放空间再继续 | 跳过 |
42
+ | `microCompact`(回填 tool 结果时) | 把单个超长工具输出就地压短,省 token | 跳过 |
43
+ | `allConcurrencySafe` 分支 + 事件队列 | 本轮工具**全是只读**才并行执行,否则串行(杜绝并行写同一文件) | 跳过 |
44
+ | `lastFinishReason === 'length'` 警告 | 模型答案被 max_tokens 截断时提醒用户「这是半截」 | 跳过 |
45
+ | `reasoningContent / reasoning / reasoning_details` | 累积思维链(DeepSeek-R1 / MiniMax 等),下一轮原样回传防「失忆」 | 跳过 |
46
+ | `error` 事件的 `code`(llm_error / max_turns / …) | 让 `-p --output-format json` 把终结原因映射成稳定的 stop_reason(给宿主 spawn 用) | 跳过 |
47
+ | `options.chat` / `options.executeTool` | 依赖注入:测试时传 stub,零进程污染;生产走真实模块单例 | 跳过 |
48
+
49
+ ## 时序图(主干)
50
+
51
+ ```
52
+ 用户输入
53
+ │
54
+ ▼
55
+ runQuery ──push user 消息──► history
56
+ │
57
+ ├─◄─────────────────────────┐ (每轮 turn)
58
+ ▼ │
59
+ chat(LLM) ──流式 text / tool_call──► 拼成 assistant 消息 → history
60
+ │ │
61
+ ├── 无 tool_calls ──► turn_done ──► return(最终回答)
62
+ │ │
63
+ └── 有 tool_calls │
64
+ │ │
65
+ ▼ │
66
+ executeTool ×N ──► tool 结果 → history ──┘(回到下一轮,模型看到结果再推理)
67
+ ```
68
+
69
+ ## 延伸阅读(按需,第几步用到再看)
70
+
71
+ - **`src/types.ts`** —— 类型字典。先看 `Message`(4 种 role)、`ToolCall`、`LlmStreamEvent`(LLM 流式块)、`LoopEvent`(loop 给 UI 的事件)。读主循环前先扫一遍这四个就够。
72
+ - **`src/llm/client.ts`** —— `chat()` 的实现:把 provider 的 SSE 流解析成 `LlmStreamEvent`。对应主干第 3 步。
73
+ - **`src/tools/index.ts`** —— `ALL_TOOLS`(工具清单)+ `executeTool()`:参数防线(JSON.parse → Zod 校验 → 调 `tool.call()`,**工具失败不抛异常**,把 error 喂回 LLM 让它重试)。对应主干第 5 步。
74
+ - **`src/context/compact.ts`** —— 自动压缩入口(进阶层)。想搞懂「上下文怎么不撑爆」再看。
75
+ - **`src/loop.ts`** —— 主角本身。读完本文按上面 6 步对照源码,再回头看进阶层。
76
+
77
+ > 一句话给初学者:**先把 6 步主干跑通,把进阶层当黑盒**;等你能复述「输入怎么变成最终回答」了,再一个个揭开硬化层——每揭开一个,你就多懂一点「为什么生产级 agent 不只是一个 while 循环」。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
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>",
@@ -45,6 +45,7 @@
45
45
  "workflows/**",
46
46
  "vendor/ripgrep/**",
47
47
  "README.md",
48
+ "MINIMAL-TRACE.md",
48
49
  "LICENSE"
49
50
  ],
50
51
  "scripts": {
@@ -54,6 +55,7 @@
54
55
  "clean": "bun scripts/clean-build.ts",
55
56
  "test": "bun test",
56
57
  "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
58
+ "verify-all": "bun scripts/verify-all.ts",
57
59
  "compile:green": "bun scripts/build-green.ts",
58
60
  "compile:green:host": "bun scripts/build-green.ts --host",
59
61
  "prepublishOnly": "bun run clean && bun run build"
@@ -57,8 +57,20 @@ Start a Ralph loop in your current session.
57
57
  ```
58
58
 
59
59
  **Options:**
60
- - `--max-iterations <n>` - Stop after N iterations (default: unlimited)
61
- - `--completion-promise <text>` - Phrase that signals completion
60
+ - `--max-iterations <n>` - Stop after N iterations (default: 50, hard cap: 200)
61
+ - `--completion-promise <text>` - Phrase that signals completion (natively active; default: DONE / COMPLETE / GOAL_COMPLETE)
62
+ - `--verify "<CHECK>"` - Completion gate run after the promise tag. All checks must pass before the loop exits. Repeatable.
63
+ - `shell:<cmd>` — exit code 0 = pass (Windows: `cmd /c`, others: `bash -c`)
64
+ - `file_exists:<path>` — file must exist
65
+ - `file_contains:<file>:<regex>` — file content matches regex
66
+ - `test_count:<min>` — `bun test` pass count ≥ min
67
+
68
+ Example:
69
+ ```bash
70
+ /ralph-loop "Implement and test cache layer" \
71
+ --verify "shell:bun test" \
72
+ --verify "file_exists:dist/index.js"
73
+ ```
62
74
 
63
75
  ### /cancel-ralph
64
76
 
@@ -116,6 +128,8 @@ Implement feature X following TDD:
116
128
  7. Output: <promise>COMPLETE</promise>
117
129
  ```
118
130
 
131
+ Tag lessons inline with `<learning>…</learning>` — the driver archives them to `learnings.md` automatically, so subsequent iterations inherit the hard-won knowledge without repeating mistakes.
132
+
119
133
  ### 4. Escape Hatches
120
134
 
121
135
  Always use `--max-iterations` as a safety net to prevent infinite loops on impossible tasks:
@@ -131,7 +145,11 @@ Always use `--max-iterations` as a safety net to prevent infinite loops on impos
131
145
  # - Suggest alternative approaches"
132
146
  ```
133
147
 
134
- **Note**: The `--completion-promise` uses exact string matching, so you cannot use it for multiple completion conditions (like "SUCCESS" vs "BLOCKED"). Always rely on `--max-iterations` as your primary safety mechanism.
148
+ Claude can also signal early exit using two distinct tags:
149
+ - `<PROMISE>NEED_REPLAN</PROMISE>` — current approach is unworkable; loop returns to PLAN with the same goal
150
+ - `<PROMISE>NEED_GOAL_REVISION</PROMISE>` — the goal itself is unreachable; loop stops cleanly for human review. Use this when no approach can work, not just the current one.
151
+
152
+ **Note**: The `--completion-promise` uses exact string matching and is natively active (no external stop-hook required). It signals Claude's intent to complete; use `--verify` checks for objective verification of that intent.
135
153
 
136
154
  ## Philosophy
137
155
 
@@ -42,8 +42,13 @@ Start a Ralph loop in your current session.
42
42
  ```
43
43
 
44
44
  **Options:**
45
- - `--max-iterations <n>` - Max iterations before auto-stop
46
- - `--completion-promise <text>` - Promise phrase to signal completion
45
+ - `--max-iterations <n>` - Max iterations before auto-stop (default: 50, hard cap: 200)
46
+ - `--completion-promise <text>` - Custom phrase to signal completion (now fully active; default phrases DONE/COMPLETE/GOAL_COMPLETE also work)
47
+ - `--verify "<CHECK>"` - Completion gate: ALL checks must pass after the promise tag before the loop truly exits. Repeatable. Supported CHECK types:
48
+ - `shell:<cmd>` — exit code 0 = pass (Windows: `cmd /c`, others: `bash -c`)
49
+ - `file_exists:<path>` — file must exist
50
+ - `file_contains:<file>:<regex>` — file content matches regex
51
+ - `test_count:<min>` — `bun test` pass count ≥ min
47
52
 
48
53
  **How it works:**
49
54
  1. Creates `.claude/.ralph-loop.local.md` state file
@@ -75,13 +80,19 @@ Cancel an active Ralph loop (removes the loop state file).
75
80
 
76
81
  ### Completion Promises
77
82
 
78
- To signal completion, Claude must output a `<promise>` tag:
83
+ To signal completion, Claude outputs a `<promise>` tag:
79
84
 
80
85
  ```
81
86
  <promise>TASK COMPLETE</promise>
82
87
  ```
83
88
 
84
- The stop hook looks for this specific tag. Without it (or `--max-iterations`), Ralph runs infinitely.
89
+ The driver looks for this tag (or the custom `--completion-promise` phrase). This signals intent to complete — the loop then runs any `--verify` checks before truly exiting.
90
+
91
+ **Escape hatches** (output these tags to exit early):
92
+ - `<PROMISE>NEED_REPLAN</PROMISE>` — current approach is unworkable, replan with same goal
93
+ - `<PROMISE>NEED_GOAL_REVISION</PROMISE>` — the goal itself is unreachable; loop stops cleanly for human review (distinct from NEED_REPLAN: that swaps approach, this flags the goal)
94
+
95
+ **Lessons tagging**: wrap insights with `<learning>…</learning>` and the driver auto-archives them to `learnings.md` for reuse in subsequent iterations.
85
96
 
86
97
  ### Self-Reference Mechanism
87
98
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: "Start Ralph Wiggum loop in current session"
3
- argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
3
+ argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT] [--verify \"CHECK\" ...]"
4
4
  allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
5
5
  hide-from-slash-command-tool: "true"
6
6
  ---
@@ -44,16 +44,42 @@ progress.md / learnings.md / decisions.md)**由本插件自动创建并维护*
44
44
 
45
45
  ## 完成哨兵
46
46
 
47
- 当且仅当任务真正完成时,输出:
47
+ 当且仅当任务真正完成时,输出(驱动进入验证门):
48
48
  ```
49
49
  <promise>DONE</promise>
50
50
  ```
51
+ 也可使用 `--completion-promise` 指定的自定义短语,驱动同样认可。
51
52
 
52
- 如果发现方案根本不可行需要重新规划:
53
+ 如果发现**当前方案**根本不可行,需要换方案重新规划(目标不变):
53
54
  ```
54
55
  <PROMISE>NEED_REPLAN</PROMISE>
55
56
  ```
56
57
 
58
+ 如果**目标本身**有问题、无法用任何方案达成,需要人工修订目标时(独立逃生门,循环干净停止):
59
+ ```
60
+ <PROMISE>NEED_GOAL_REVISION</PROMISE>
61
+ ```
62
+ > 区别:NEED_REPLAN = 换方案(目标不变);NEED_GOAL_REVISION = 目标本身有问题,请人工介入。
63
+
64
+ ## 验证门 --verify
65
+
66
+ 命中完成哨兵后,驱动会运行所有 `--verify` CHECK,**全部通过才真正退出**,否则回 BUILD 继续。
67
+ 可重复多次,支持 4 种 CHECK 类型:
68
+
69
+ - `shell:<命令>` —— 退出码 0 = 通过(Windows 走 `cmd /c`,其余 `bash -c`)
70
+ - `file_exists:<路径>` —— 文件存在
71
+ - `file_contains:<文件>:<正则>` —— 文件内容匹配正则
72
+ - `test_count:<最小数>` —— 跑 `bun test`,统计到的通过数 ≥ 最小数
73
+
74
+ 示例:
75
+ ```
76
+ /ralph-loop "实现并测试缓存层" --verify "shell:bun test" --verify "file_exists:src/cache.ts"
77
+ ```
78
+
57
79
  ## 踩坑记录
58
80
 
59
- 如果在工作中发现任何值得记录的教训(坑、陷阱、最佳实践),请明确说明,系统会自动记录到 `.minimal-agent/learnings.md` 供后续迭代参考。
81
+ 如果在工作中发现任何值得记录的教训(坑、陷阱、最佳实践),用 `<learning>…</learning>` 标记:
82
+ ```
83
+ <learning>直接写文件前必须先 Read,否则 Edit 会报错</learning>
84
+ ```
85
+ 驱动每轮自动把 learning 存档到 `.minimal-agent-ralph-wiggum/learnings.md`,后续迭代会自动读入上下文复用。
@@ -24,7 +24,7 @@ import { dirname } from 'node:path';
24
24
  import { runQuery, getWorkingDir, } from '../../src/plugin-sdk.js';
25
25
  import { GoalState, Phase } from './src/goalState.js';
26
26
  import { parseVerifyArgs, runVerification } from './src/verificationGate.js';
27
- import { hasCompleteSentinel, hasNeedReplanSentinel, } from './src/sentinels.js';
27
+ import { makeCompletionMatcher, hasNeedReplanSentinel, hasNeedGoalRevisionSentinel, extractLearnings, } from './src/sentinels.js';
28
28
  import { executeStopHook } from './src/stopHookRunner.js';
29
29
  const PLUGIN_NAME = 'ralph-wiggum';
30
30
  const DEFAULT_MAX_ITERATIONS = 50;
@@ -34,11 +34,32 @@ function extractMaxIterations(args) {
34
34
  const match = args.match(/--max-iterations\s+(\d+)/i);
35
35
  return match ? parseInt(match[1], 10) : undefined;
36
36
  }
37
+ /** 解析 --completion-promise "TEXT"(带引号优先)或 --completion-promise TOKEN。 */
38
+ function extractCompletionPromise(args) {
39
+ const quoted = args.match(/--completion-promise\s+"([^"]*)"/i);
40
+ if (quoted)
41
+ return quoted[1].trim() || undefined;
42
+ const token = args.match(/--completion-promise\s+(\S+)/i);
43
+ return token ? token[1].trim() || undefined : undefined;
44
+ }
45
+ /** 从 args 里剥掉控制 flag,得到喂给 LLM 的"干净目标文本"(避免 --verify 等泄漏进 prompt)。 */
46
+ function stripControlFlags(args) {
47
+ return args
48
+ .replace(/--max-iterations\s+\d+/gi, '')
49
+ .replace(/--completion-promise\s+"[^"]*"/gi, '')
50
+ .replace(/--completion-promise\s+\S+/gi, '')
51
+ .replace(/--verify\s+"[^"]*"/gi, '')
52
+ .replace(/--verify\s+\S+/gi, '')
53
+ .replace(/\s{2,}/g, ' ')
54
+ .trim();
55
+ }
37
56
  async function* runRalphLoop(args, ctx) {
38
57
  const { provider, history, signal } = ctx;
39
58
  const maxIter = Math.min(extractMaxIterations(args) ?? DEFAULT_MAX_ITERATIONS, SAFETY_CEILING);
40
59
  // 没有 args(用户只敲 /ralph-loop)→ 留个最小 goal placeholder,避免 GoalState.init 拒空串
41
- const userGoal = args.trim() || '(未提供目标)';
60
+ // 控制 flag(--max-iterations / --verify / --completion-promise)都从原始 args 解析,
61
+ // userGoal 才用剥过的干净文本,避免 flag 泄漏进喂给 LLM 的 prompt
62
+ const userGoal = stripControlFlags(args) || '(未提供目标)';
42
63
  yield {
43
64
  type: 'plugin_progress',
44
65
  pluginId: PLUGIN_NAME,
@@ -47,6 +68,10 @@ async function* runRalphLoop(args, ctx) {
47
68
  message: 'Ralph Wiggum copy-task loop 启动',
48
69
  };
49
70
  const checks = parseVerifyArgs(args);
71
+ // 完成哨兵判定器:不传 --completion-promise 只认写死集合 DONE|COMPLETE|GOAL_COMPLETE,
72
+ // 传了则在写死集合基础上叠加自定义短语(超集,向后兼容)
73
+ const completePhrase = extractCompletionPromise(args);
74
+ const isComplete = makeCompletionMatcher(completePhrase);
50
75
  // sessionTag = 插件名 → 多插件可并发不打架,/new 也能扫到清掉
51
76
  const goalState = new GoalState(getWorkingDir(), PLUGIN_NAME);
52
77
  await goalState.reset();
@@ -67,6 +92,7 @@ async function* runRalphLoop(args, ctx) {
67
92
  yield {
68
93
  type: 'error',
69
94
  error: `Loop 已达迭代上限 ${maxIter},自动停止`,
95
+ code: 'max_turns',
70
96
  };
71
97
  return;
72
98
  }
@@ -111,7 +137,11 @@ async function* runRalphLoop(args, ctx) {
111
137
  ? finalAssistantMsg.content
112
138
  : JSON.stringify(finalAssistantMsg.content)
113
139
  : '';
114
- if (hasCompleteSentinel(lastAssistantText)) {
140
+ // 每轮提取 <learning> 并落盘,让经验跨 fresh-context 迭代存活
141
+ for (const lesson of extractLearnings(lastAssistantText)) {
142
+ await goalState.appendLearning(`[迭代 ${iterationCount}] ${lesson}`);
143
+ }
144
+ if (isComplete(lastAssistantText)) {
115
145
  // 哨兵:可能来自任意阶段(包括 iter 1 的 PLAN),用 force 跳到 VERIFY
116
146
  await goalState.forceSetPhase(Phase.VERIFY, '检测到完成哨兵,进入验证');
117
147
  await goalState.appendProgress(`迭代 ${iterationCount}: 检测到完成哨兵,运行验证门...`);
@@ -120,26 +150,66 @@ async function* runRalphLoop(args, ctx) {
120
150
  if (!vResult.passed) {
121
151
  consecutiveFailures++;
122
152
  await goalState.appendLearning(`[迭代 ${iterationCount}] 声称完成但验证未通过: ${vResult.summary}`);
153
+ // 验证未通过不是致命错误:降级为 warning(不映射 stop_reason),
154
+ // 再补一条进度保活进度条,避免 UI 误以为循环停了
155
+ yield {
156
+ type: 'warning',
157
+ message: `⚠️ 验证未通过: ${vResult.summary}。继续尝试...`,
158
+ };
123
159
  yield {
124
- type: 'error',
125
- error: `⚠️ 验证未通过: ${vResult.summary}。继续尝试...`,
160
+ type: 'plugin_progress',
161
+ pluginId: PLUGIN_NAME,
162
+ current: iterationCount,
163
+ max: maxIter,
164
+ message: '验证未通过,回到构建修复',
126
165
  };
166
+ // 跨平台决策记录(不再只靠 POSIX stop-hook)
167
+ await goalState.recordDecision({
168
+ iteration: iterationCount,
169
+ phase: Phase.VERIFY,
170
+ summary: '验证门',
171
+ }, ['判定完成', '回退继续'], '回退继续', vResult.summary.slice(0, 200));
127
172
  if (consecutiveFailures >= 3) {
128
173
  await goalState.forceSetPhase(Phase.HEAL, `连续 ${consecutiveFailures} 次验证失败`);
129
174
  }
130
175
  else {
131
- await goalState.setPhase(Phase.BUILD, '验证未通过,返回构建');
176
+ // 语义诚实:VERIFY→BUILD 不是合法边,用 force 而非借合法边
177
+ await goalState.forceSetPhase(Phase.BUILD, '验证未通过,回到构建修复');
132
178
  }
133
179
  continue;
134
180
  }
135
181
  await goalState.appendProgress(`✅ 验证通过: ${vResult.summary}`);
136
182
  }
137
183
  await goalState.setPhase(Phase.DONE, 'goal complete & verified');
184
+ // 成功路径决策记录(vResult 定义在 checks.length>0 块内、此处作用域不可见,
185
+ // 故 reasoning 统一写 '目标完成',不引用 vResult)
186
+ await goalState.recordDecision({
187
+ iteration: iterationCount,
188
+ phase: Phase.VERIFY,
189
+ summary: '验证门',
190
+ }, ['判定完成', '回退继续'], '判定完成', '目标完成');
138
191
  yield {
139
- type: 'plugin_iteration',
140
- pluginName: PLUGIN_NAME,
192
+ type: 'plugin_progress',
193
+ pluginId: PLUGIN_NAME,
141
194
  current: iterationCount,
142
195
  max: maxIter,
196
+ message: '✅ 完成并通过验证',
197
+ };
198
+ return;
199
+ }
200
+ // NEED_GOAL_REVISION 逃生门:目标本身不可达,干净终止请人工修订
201
+ // (区别于 NEED_REPLAN —— 那是换方案,这是换目标)
202
+ if (hasNeedGoalRevisionSentinel(lastAssistantText)) {
203
+ await goalState.forceSetPhase(Phase.DONE, 'agent 报告目标本身需修订');
204
+ await goalState.appendLearning('[NEED_GOAL_REVISION] Agent 认为目标本身有问题,需人工修订');
205
+ await goalState.recordDecision({
206
+ iteration: iterationCount,
207
+ phase: goalState.currentPhase,
208
+ summary: 'NEED_GOAL_REVISION',
209
+ }, ['继续硬上', '停止请人工改目标'], '停止请人工改目标', '目标本身不可达');
210
+ yield {
211
+ type: 'warning',
212
+ message: '🛑 Agent 判断目标本身需要人工修订,循环已停止。请检查并调整目标后重试。',
143
213
  };
144
214
  return;
145
215
  }
@@ -147,6 +217,11 @@ async function* runRalphLoop(args, ctx) {
147
217
  await goalState.forceSetPhase(Phase.PLAN, 'agent 请求重新规划');
148
218
  await goalState.appendLearning('[NEED_REPLAN] Agent 认为当前方案不可行,需要重新规划');
149
219
  await goalState.appendProgress('Agent 请求 NEED_REPLAN,回 PLAN 阶段');
220
+ await goalState.recordDecision({
221
+ iteration: iterationCount,
222
+ phase: goalState.currentPhase,
223
+ summary: 'NEED_REPLAN',
224
+ }, ['继续当前方案', '回 PLAN 重规划'], '回 PLAN 重规划', 'agent 认为方案不可行');
150
225
  consecutiveFailures = 0;
151
226
  continue;
152
227
  }
@@ -10,6 +10,19 @@ export var Phase;
10
10
  Phase["DONE"] = "done";
11
11
  })(Phase || (Phase = {}));
12
12
  const VALID_PHASES = new Set(Object.values(Phase));
13
+ /**
14
+ * 阶段转移表使用说明(诚实注释):
15
+ *
16
+ * 这张表里的 event 名(plan_complete / tests_failing / all_pass 等)是**描述性的**,
17
+ * 阶段实际由驱动(plugin.ts)用启发式推进或 `forceSetPhase` 直接设置——
18
+ * **不是 LLM 驱动的事件状态机**。
19
+ *
20
+ * `setPhase` 只用这张表做「目标阶段是否可达」的合法性校验:
21
+ * 只要 PHASE_TRANSITIONS[from] 的 values 里包含 to,就视为合法,跟传入的 `reason` 字符串无关。
22
+ * `reason` 仅是给人看的日志文本,不参与任何匹配或校验。
23
+ *
24
+ * 想跳过校验强制切阶段,用 `forceSetPhase`。
25
+ */
13
26
  const PHASE_TRANSITIONS = {
14
27
  [Phase.PLAN]: {
15
28
  plan_complete: Phase.BUILD,
@@ -40,6 +53,14 @@ function isLegalTransition(from, to) {
40
53
  return Object.values(allowed).includes(to);
41
54
  }
42
55
  const LEARNINGS_TAIL_LINES = 20;
56
+ /** 每个阶段给 LLM 的一行行动指令(fresh-context 下 LLM 看不到 SKILL.md,靠这个 steer 行为)。 */
57
+ const PHASE_DIRECTIVES = {
58
+ [Phase.PLAN]: '分析任务、拆解步骤、识别风险与未知项;先想清楚再动手。',
59
+ [Phase.BUILD]: '按计划实现,一次只聚焦一个子任务,落到具体文件改动。',
60
+ [Phase.VERIFY]: '运行测试 / 检查输出 / 核对验收标准,用证据确认,别凭感觉。',
61
+ [Phase.HEAL]: '已多次验证失败:停止猜测,定位根因(读报错、加日志、做最小复现)再修,避免回归。',
62
+ [Phase.DONE]: '目标已达成。',
63
+ };
43
64
  export class GoalState {
44
65
  dir;
45
66
  constructor(workspaceDir, sessionTag) {
@@ -165,30 +186,6 @@ export class GoalState {
165
186
  const line = JSON.stringify(entry);
166
187
  await appendFile(join(this.dir, 'decisions.md'), `${line}\n`);
167
188
  }
168
- findSimilarDecisions(ctx, k = 3) {
169
- try {
170
- const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
171
- const lines = content.trim().split('\n').filter(Boolean);
172
- const entries = [];
173
- for (const line of lines) {
174
- try {
175
- entries.push(JSON.parse(line));
176
- }
177
- catch {
178
- }
179
- }
180
- const scored = entries
181
- .map((entry) => ({
182
- entry,
183
- score: this._similarity(entry.contextSummary, ctx.summary),
184
- }))
185
- .sort((a, b) => b.score - a.score);
186
- return scored.slice(0, k).filter((s) => s.score > 0.3).map((s) => s.entry);
187
- }
188
- catch {
189
- return [];
190
- }
191
- }
192
189
  summarizeDecisions(maxEntries = 5) {
193
190
  try {
194
191
  const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
@@ -201,8 +198,11 @@ export class GoalState {
201
198
  catch {
202
199
  }
203
200
  }
204
- return entries
205
- .slice(-maxEntries)
201
+ const sliced = entries.slice(-maxEntries);
202
+ // 无有效条目(文件存在但为空)时也统一返回占位文本
203
+ if (sliced.length === 0)
204
+ return '(无决策记录)';
205
+ return sliced
206
206
  .map((e) => `迭代 ${e.iteration}(${e.phase}): 在 [${e.options.join(', ')}] 中选择了 **${e.chosen}**,原因:${e.reasoning.slice(0, 80)}`)
207
207
  .join('\n');
208
208
  }
@@ -212,8 +212,19 @@ export class GoalState {
212
212
  }
213
213
  composeContext(iteration) {
214
214
  const sections = [];
215
- sections.push(`# 不可变目标 (IMMUTABLE GOAL)\n${this.goal}\n⚠️ 注意:你不能修改或扩大上述目标。如果你认为目标本身有问题,请输出 <PROMISE>NEED_GOAL_REVISION</PROMISE> 并停止。`);
216
- sections.push(`# 当前阶段: ${this.currentPhase.toUpperCase()}`);
215
+ // 不可变目标段:去掉 NEED_GOAL_REVISION 提示(已移到通信协议段统一说明)
216
+ sections.push(`# 不可变目标 (IMMUTABLE GOAL)\n${this.goal}\n⚠️ 你不能修改或扩大上述目标。`);
217
+ // 当前阶段 + 对应的一行行动指令
218
+ sections.push(`# 当前阶段: ${this.currentPhase.toUpperCase()}\n${PHASE_DIRECTIVES[this.currentPhase]}`);
219
+ // 与循环驱动的通信协议:哨兵约定 + learning 标记
220
+ sections.push([
221
+ '# 与循环驱动的通信协议(务必遵守)',
222
+ '- 任务**真正完成**时输出 `<promise>DONE</promise>`,驱动会运行验证门确认。',
223
+ '- 当前方案走不通、要换打法(目标不变)时输出 `<PROMISE>NEED_REPLAN</PROMISE>`。',
224
+ '- **目标本身**有问题、不改目标就无法完成时输出 `<PROMISE>NEED_GOAL_REVISION</PROMISE>`,驱动会停止并请人工修订。',
225
+ '- 踩到的坑 / 教训用 `<learning>一句话</learning>` 标记,驱动会自动存档供后续迭代参考。',
226
+ '⚠️ 切勿为逃出循环而谎报完成——只有陈述完全为真时才输出完成哨兵。',
227
+ ].join('\n'));
217
228
  const lrn = this.learnings;
218
229
  if (lrn) {
219
230
  sections.push(`# 关键教训(必须遵守,避免重复踩坑)\n${lrn}`);
@@ -244,17 +255,4 @@ export class GoalState {
244
255
  catch {
245
256
  }
246
257
  }
247
- _similarity(a, b) {
248
- if (!a || !b)
249
- return 0;
250
- const wordsA = new Set(a.toLowerCase().split(/\s+/));
251
- const wordsB = new Set(b.toLowerCase().split(/\s+/));
252
- let intersection = 0;
253
- for (const word of wordsA) {
254
- if (wordsB.has(word))
255
- intersection++;
256
- }
257
- const union = new Set([...wordsA, ...wordsB]).size;
258
- return union > 0 ? intersection / union : 0;
259
- }
260
258
  }
@@ -1,21 +1,76 @@
1
1
  /**
2
2
  * ============================================================
3
- * plugins/ralph-wiggum/src/sentinels.ts —— LLM 输出哨兵正则
3
+ * plugins/ralph-wiggum/src/sentinels.ts —— LLM 输出哨兵协议
4
4
  * ------------------------------------------------------------
5
- * Ralph 通过"输出特定标签"与 driver 交流:
5
+ * Ralph 通过"在回答里输出特定标签"与循环驱动(plugin.ts)通信:
6
+ *
6
7
  * <promise>DONE</promise> / <promise>COMPLETE</promise> / <promise>GOAL_COMPLETE</promise>
7
- * → 表示当前轮 LLM 认为目标完成,driver 进入验证门
8
+ * (或用户经 `--completion-promise "<短语>"` 指定的自定义短语)
9
+ * → 当前轮 LLM 认为目标完成;驱动进入验证门(--verify)确认后才真正退出。
10
+ *
8
11
  * <PROMISE>NEED_REPLAN</PROMISE>
9
- * → 表示当前方案不可行,driver 强转回 PLAN 阶段
12
+ * → 当前方案走不通、需要换打法(**目标不变**);驱动强转回 PLAN 阶段。
13
+ *
14
+ * <PROMISE>NEED_GOAL_REVISION</PROMISE>
15
+ * → **目标本身**有问题,无法在不改目标的前提下完成;驱动**干净终止**,请人工修订目标
16
+ * (区别于 NEED_REPLAN:那是换方案,这是换目标)。
17
+ *
18
+ * <learning>一句话</learning>(可多次)
19
+ * → LLM 主动记录的踩坑 / 教训;驱动**每轮**提取并追加到 learnings.md,
20
+ * 让经验跨 fresh-context 迭代存活——这是 Ralph 能收敛的关键。
10
21
  *
11
- * 哨兵集中在这里方便审计与扩展;正则全部 case-insensitive。
22
+ * 哨兵集中在这里方便审计与扩展;匹配全部 case-insensitive。
23
+ * ⚠️ rich 插件路径下 commands/ralph-loop.md 的 prompt body **不会**喂给 LLM
24
+ * (pluginRunner 命中 runCommand 即接管),故以上协议由 GoalState.composeContext
25
+ * 注入 prompt,本文件只负责"解析 LLM 回吐的标签"。
12
26
  * ============================================================
13
27
  */
14
- const COMPLETE_RE = /<promise>(?:COMPLETE|DONE|GOAL_COMPLETE)<\/promise>/i;
28
+ /** 写死的完成短语集合(不带 --completion-promise 时只认这三个,逐字节兼容旧行为)。 */
29
+ const BUILTIN_COMPLETE_RE = /<promise>(?:COMPLETE|DONE|GOAL_COMPLETE)<\/promise>/i;
15
30
  const NEED_REPLAN_RE = /<PROMISE>NEED_REPLAN<\/PROMISE>/i;
31
+ const NEED_GOAL_REVISION_RE = /<PROMISE>NEED_GOAL_REVISION<\/PROMISE>/i;
32
+ const LEARNING_RE = /<learning>([\s\S]*?)<\/learning>/gi;
33
+ /** 转义正则元字符,让用户自定义的 promise 短语按字面量匹配(含空格 / 标点也安全)。 */
34
+ function escapeRegExp(s) {
35
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ }
37
+ /**
38
+ * 构造完成哨兵判定器。
39
+ * - 不传 / 空白 customPhrase → 只认写死集合(与旧行为逐字节一致)。
40
+ * - 传 customPhrase → 写死集合 ∪ `<promise><customPhrase></promise>`(**超集**,向后兼容)。
41
+ * customPhrase 内外空白裁剪;正则元字符转义;标签内允许首尾空白;大小写不敏感。
42
+ *
43
+ * 设计:始终接受写死集合,所以即便用户设了自定义短语,LLM 输出 `<promise>DONE</promise>`
44
+ * 仍然有效——composeContext 因此可以无脑教 LLM 用 DONE,不必知道自定义短语是什么。
45
+ */
46
+ export function makeCompletionMatcher(customPhrase) {
47
+ const phrase = customPhrase?.trim();
48
+ if (!phrase) {
49
+ return (text) => BUILTIN_COMPLETE_RE.test(text);
50
+ }
51
+ const customRe = new RegExp(`<promise>\\s*${escapeRegExp(phrase)}\\s*<\\/promise>`, 'i');
52
+ return (text) => BUILTIN_COMPLETE_RE.test(text) || customRe.test(text);
53
+ }
54
+ /** 旧调用兼容:等价于 makeCompletionMatcher()(text),只认写死集合。 */
16
55
  export function hasCompleteSentinel(text) {
17
- return COMPLETE_RE.test(text);
56
+ return BUILTIN_COMPLETE_RE.test(text);
18
57
  }
19
58
  export function hasNeedReplanSentinel(text) {
20
59
  return NEED_REPLAN_RE.test(text);
21
60
  }
61
+ export function hasNeedGoalRevisionSentinel(text) {
62
+ return NEED_GOAL_REVISION_RE.test(text);
63
+ }
64
+ /**
65
+ * 提取 LLM 本轮输出里所有 `<learning>…</learning>` 文本。
66
+ * 多空白折叠成单空格、去首尾、丢弃空项;无标签返回空数组。
67
+ */
68
+ export function extractLearnings(text) {
69
+ const out = [];
70
+ for (const m of text.matchAll(LEARNING_RE)) {
71
+ const lesson = m[1].replace(/\s+/g, ' ').trim();
72
+ if (lesson)
73
+ out.push(lesson);
74
+ }
75
+ return out;
76
+ }