minimal-agent 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/package.json +1 -1
- package/plugins/workflow-runner/plugin.js +1 -0
- package/plugins/workflow-runner/src/agentCommon.js +82 -47
- package/plugins/workflow-runner/src/expressions.js +48 -2
- package/plugins/workflow-runner/src/index.js +1 -0
- package/plugins/workflow-runner/src/jsonPayload.js +1 -1
- package/plugins/workflow-runner/src/loader.js +70 -8
- package/plugins/workflow-runner/src/recovery.js +43 -35
- package/plugins/workflow-runner/src/runner.js +143 -34
- package/plugins/workflow-runner/src/stepExecutors/agent.js +64 -35
- package/plugins/workflow-runner/src/stepExecutors/assert.js +14 -2
- package/plugins/workflow-runner/src/stepExecutors/llm.js +48 -107
- package/plugins/workflow-runner/src/stepExecutors/skill.js +50 -39
- package/plugins/workflow-runner/src/stepExecutors/tool.js +20 -7
- package/plugins/workflow-runner/src/stepExecutors/verify.js +11 -3
- package/plugins/workflow-runner/src/taskIdentity.js +141 -0
- package/plugins/workflow-runner/src/types.js +1 -2
- package/plugins/workflow-runner/src/workflowState.js +211 -28
- package/skills/adversarial-review/SKILL.md +0 -1
- package/skills/pptx/scripts/office/__pycache__/soffice.cpython-314.pyc +0 -0
- package/src/askBridge.js +10 -0
- package/src/context/archive.js +4 -0
- package/src/infra/verification.js +25 -13
- package/src/llm/client.js +160 -12
- package/src/loop.js +15 -12
- package/src/plugins/pluginLoader.js +2 -1
- package/src/plugins/pluginRunner.js +9 -21
- package/src/prompts/pluginList.js +5 -5
- package/src/prompts/promptAssembly.js +3 -38
- package/src/prompts/skillList.js +1 -1
- package/src/prompts/system.js +0 -1
- package/src/ui/AskQuestion.js +20 -3
- package/src/ui/InputBox.js +70 -3
- package/src/ui/hooks/useChat.js +13 -0
- package/workflows/adaptive-research.yaml +4 -3
- package/workflows/book-review-short.yaml +5 -3
- package/workflows/e2e-write-greet.yaml +3 -2
- package/workflows/plan.yaml +219 -168
- package/workflows/ralph-loop.yaml +169 -0
- package/workflows/youtube-shorts.yaml +8 -5
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +0 -186
- package/plugins/ralph-wiggum/.claude-plugin/plugin.json +0 -9
- package/plugins/ralph-wiggum/README.md +0 -197
- package/plugins/ralph-wiggum/commands/cancel-ralph.md +0 -18
- package/plugins/ralph-wiggum/commands/help.md +0 -137
- package/plugins/ralph-wiggum/commands/ralph-loop.md +0 -85
- package/plugins/ralph-wiggum/hooks/hooks.json +0 -15
- package/plugins/ralph-wiggum/hooks/stop-hook.sh +0 -191
- package/plugins/ralph-wiggum/plugin.js +0 -280
- package/plugins/ralph-wiggum/src/goalState.js +0 -258
- package/plugins/ralph-wiggum/src/sentinels.js +0 -76
- package/plugins/ralph-wiggum/src/stopHookRunner.js +0 -104
- package/plugins/ralph-wiggum/src/verificationGate.js +0 -202
- package/plugins/workflow-runner/src/miniReAct.js +0 -223
package/src/askBridge.js
CHANGED
|
@@ -46,6 +46,16 @@ export async function requestUserChoice(questions, signal) {
|
|
|
46
46
|
// 非交互兜底
|
|
47
47
|
return questions.map((q) => {
|
|
48
48
|
const pick = q.options[0]?.label ?? '';
|
|
49
|
+
// 约定校验:askBridge.ts:25 JSDoc 说"把推荐项放在第一个,并在 label 末尾标「(推荐)」。
|
|
50
|
+
// 若首项 label 不含 "(推荐)",stderr 注记一下——上游作者违反约定
|
|
51
|
+
// (用户被自动套上非推荐路径是糟糕的体验)
|
|
52
|
+
const firstOpt = q.options[0];
|
|
53
|
+
if (firstOpt && !firstOpt.label.includes('(推荐)')) {
|
|
54
|
+
try {
|
|
55
|
+
process.stderr.write(`[AskUserQuestion·非交互] "${q.header}" 首项 label "${firstOpt.label}" 不含「(推荐)」标记——非交互宿主会按首项兜底,违反 promptAssembly 里的提问克制约定\n`);
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
49
59
|
try {
|
|
50
60
|
process.stderr.write(`[AskUserQuestion·非交互] "${q.header}" 无 UI,自动选推荐项:"${pick}"\n`);
|
|
51
61
|
}
|
package/src/context/archive.js
CHANGED
|
@@ -51,6 +51,10 @@ export async function archiveCurrent(messages) {
|
|
|
51
51
|
await mkdir(dir, { recursive: true });
|
|
52
52
|
const id = new Date().toISOString().replace(/:/g, '-');
|
|
53
53
|
const path = join(dir, `${id}.json`);
|
|
54
|
+
// 写入完整 messages(含 system)—— restoreArchive 期待原样还原以做 roundtrip
|
|
55
|
+
// 跳过判定单独用 nonSystem(空对话不归档)—— 这是有意分层的:
|
|
56
|
+
// skip 判定 = "有没有用户/助手消息可归档"
|
|
57
|
+
// write 内容 = "保留完整上下文供 /restore 还原"
|
|
54
58
|
await writeFile(path, JSON.stringify({ updatedAt: Date.now(), messages }), 'utf8');
|
|
55
59
|
return id;
|
|
56
60
|
}
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* ============================================================
|
|
3
3
|
* src/infra/verification.ts —— 共享验证基础设施
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
|
-
* 从 plugins/ralph-wiggum/src/verificationGate.ts 提升为共享模块。
|
|
6
|
-
*
|
|
7
5
|
* 支持四种检查器(跨平台):
|
|
8
6
|
* - shell:<cmd> 跨平台 spawn(Windows 走 cmd /c, 其余 bash -c)
|
|
9
7
|
* - file_exists:<path> fs.existsSync
|
|
@@ -11,8 +9,8 @@
|
|
|
11
9
|
* - test_count:<min> 跑 `bun test`,从 stdout 解析 "N pass"
|
|
12
10
|
*
|
|
13
11
|
* 消费者:
|
|
14
|
-
* - ralph-wiggum 插件(循环验证门)
|
|
15
12
|
* - workflow-runner 插件(verify 步骤类型)
|
|
13
|
+
* - 工作流 YAML 的 type: verify 步骤
|
|
16
14
|
* - 未来任何需要声明式验证的插件或模块
|
|
17
15
|
*
|
|
18
16
|
* 设计原则:
|
|
@@ -59,7 +57,7 @@ function parseVerifyArg(arg) {
|
|
|
59
57
|
/**
|
|
60
58
|
* 从命令行参数字符串中提取所有 --verify 项。
|
|
61
59
|
* 兼容两种调用方式:
|
|
62
|
-
* -
|
|
60
|
+
* - 工作流 / 命令的 args 字符串:`"goal --verify 'shell:bun test'"`
|
|
63
61
|
* - workflow verify 步骤的 checks 数组元素:`"shell:bun test"`
|
|
64
62
|
*/
|
|
65
63
|
export function parseVerifyArgs(args) {
|
|
@@ -74,10 +72,25 @@ export function parseVerifyArgs(args) {
|
|
|
74
72
|
}
|
|
75
73
|
return checks;
|
|
76
74
|
}
|
|
77
|
-
/** 从单个 check 字符串解析(workflow verify 步骤用)。
|
|
75
|
+
/** 从单个 check 字符串解析(workflow verify 步骤用)。
|
|
76
|
+
* parseVerifyArg 是底层解析器;本函数保留独立名字是因为 verify.ts:14 直接 import 它。
|
|
77
|
+
* parseVerifyArgs 用于从命令行参数字符串抓 --verify 项——两者职责不重叠。 */
|
|
78
78
|
export function parseCheckString(raw) {
|
|
79
79
|
return parseVerifyArg(raw);
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* 判断路径是否为绝对路径(POSIX / Windows drive / Windows UNC)。
|
|
83
|
+
* - POSIX: /usr/local/...
|
|
84
|
+
* - Win drive: C:\Users\... 或 C:/foo
|
|
85
|
+
* - UNC: \\server\share\...
|
|
86
|
+
* 之前漏识别 UNC → 会被错误地拼上 workingDir 前缀,file_exists 永远查不到。
|
|
87
|
+
*/
|
|
88
|
+
function isAbsolutePath(file) {
|
|
89
|
+
return (file.startsWith('/') ||
|
|
90
|
+
file.startsWith('\\') || // 同时覆盖 \foo 和 \\server(前者非常规但兼容)
|
|
91
|
+
/^[A-Za-z]:[\\/]/.test(file) // C:\ 或 C:/
|
|
92
|
+
);
|
|
93
|
+
}
|
|
81
94
|
function runShell(command, timeout) {
|
|
82
95
|
return new Promise((resolve) => {
|
|
83
96
|
const isWin = process.platform === 'win32';
|
|
@@ -116,10 +129,8 @@ export async function verifyShell(command, timeout) {
|
|
|
116
129
|
};
|
|
117
130
|
}
|
|
118
131
|
export function verifyFileExists(file) {
|
|
119
|
-
// 相对路径以 workingDir
|
|
120
|
-
const resolved = file
|
|
121
|
-
? file
|
|
122
|
-
: `${getWorkingDir()}/${file}`;
|
|
132
|
+
// 相对路径以 workingDir 为锚(绝对路径识别含 UNC)
|
|
133
|
+
const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
|
|
123
134
|
const exists = existsSync(resolved);
|
|
124
135
|
return {
|
|
125
136
|
check: { type: 'file_exists', file },
|
|
@@ -128,9 +139,7 @@ export function verifyFileExists(file) {
|
|
|
128
139
|
};
|
|
129
140
|
}
|
|
130
141
|
export function verifyFileContains(file, pattern) {
|
|
131
|
-
const resolved = file
|
|
132
|
-
? file
|
|
133
|
-
: `${getWorkingDir()}/${file}`;
|
|
142
|
+
const resolved = isAbsolutePath(file) ? file : `${getWorkingDir()}/${file}`;
|
|
134
143
|
try {
|
|
135
144
|
const content = readFileSync(resolved, 'utf8');
|
|
136
145
|
const regex = new RegExp(pattern);
|
|
@@ -150,7 +159,10 @@ export function verifyFileContains(file, pattern) {
|
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
async function verifyTestCount(minCount) {
|
|
153
|
-
|
|
162
|
+
// 跨运行时:开发用 bun → bun test;npm 全局安装用 Node → npm test --silent
|
|
163
|
+
// 用 process.versions.bun 探测比 typeof Bun !== 'undefined' 更稳(不依赖全局类型)
|
|
164
|
+
const cmd = process.versions.bun ? 'bun test' : 'npm test --silent';
|
|
165
|
+
const r = await runShell(cmd, 60_000);
|
|
154
166
|
const combined = `${r.stdout}\n${r.stderr}`;
|
|
155
167
|
if (r.errored) {
|
|
156
168
|
return {
|
package/src/llm/client.js
CHANGED
|
@@ -40,6 +40,87 @@ function isResponseFormatUnsupported(status, errText) {
|
|
|
40
40
|
t.includes('not valid') ||
|
|
41
41
|
t.includes('invalid'));
|
|
42
42
|
}
|
|
43
|
+
// ============================================================
|
|
44
|
+
// 重试 + 超时(无网络重试 → 整条 IM 链卡死;这是 P0-1 的根治)
|
|
45
|
+
// ------------------------------------------------------------
|
|
46
|
+
// 背景:之前 fetch 直接 await,没有 timeout 也没有重试。
|
|
47
|
+
// 后果:① 断网 1 帧流式 60+ chunk 报废;② 429/5xx 直接抛错;
|
|
48
|
+
// ③ 401/403 错误文案不带"key 失效"提示;④ gateway 桥接 stream-json 后
|
|
49
|
+
// 子进程挂起会整条 IM 通道 dir 锁卡死。
|
|
50
|
+
//
|
|
51
|
+
// 设计:
|
|
52
|
+
// - 指数退避 200ms → 400 → 800 → 1600 → 3200(封顶 5 次)
|
|
53
|
+
// - 5xx / 408 / 425 / 429 / 网络错误全部重试
|
|
54
|
+
// - 401 / 403 / 404 等 4xx 不重试(重试无意义)+ 给清晰"key 失效"提示
|
|
55
|
+
// - 429 优先解析 Retry-After 头(秒),封顶 60s
|
|
56
|
+
// - AbortSignal.timeout 120s 兜底单次 fetch 时长
|
|
57
|
+
// - 用户 signal 触发的 abort 立即退出,不重试
|
|
58
|
+
// - 流式响应中途中断(reader.read reject)走同一个错误路径
|
|
59
|
+
// ============================================================
|
|
60
|
+
/** 单一 fetch 请求的超时(毫秒)。流式响应中途不发新 chunk 也算超时。 */
|
|
61
|
+
const FETCH_TIMEOUT_MS = 120_000;
|
|
62
|
+
const DEFAULT_RETRY = {
|
|
63
|
+
maxAttempts: 5,
|
|
64
|
+
baseDelayMs: 200,
|
|
65
|
+
maxDelayMs: 3200,
|
|
66
|
+
};
|
|
67
|
+
/** 可重试的 HTTP 状态码。4xx 除 408/425/429 外不重试(重试无意义)。 */
|
|
68
|
+
function isRetryableStatus(status) {
|
|
69
|
+
return status === 408 || status === 425 || status === 429 || (status >= 500 && status < 600);
|
|
70
|
+
}
|
|
71
|
+
/** 鉴权类错误:明确告诉用户"key 失效/过期/无权访问",避免重试无意义。 */
|
|
72
|
+
function isClientAuthError(status) {
|
|
73
|
+
return status === 401 || status === 403;
|
|
74
|
+
}
|
|
75
|
+
/** 解析 Retry-After 头(秒数字符串),封顶 60s。无效/缺省返回 undefined。 */
|
|
76
|
+
function parseRetryAfter(resp) {
|
|
77
|
+
const h = resp.headers.get('retry-after');
|
|
78
|
+
if (!h)
|
|
79
|
+
return undefined;
|
|
80
|
+
const sec = parseFloat(h);
|
|
81
|
+
if (!Number.isFinite(sec) || sec < 0)
|
|
82
|
+
return undefined;
|
|
83
|
+
return Math.min(sec * 1000, 60_000);
|
|
84
|
+
}
|
|
85
|
+
/** 可中断的 sleep:用户 signal 触发 abort 立刻 reject,不阻塞退出。 */
|
|
86
|
+
function sleep(ms, signal) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (signal?.aborted) {
|
|
89
|
+
reject(new Error('aborted'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
signal?.removeEventListener('abort', onAbort);
|
|
94
|
+
resolve();
|
|
95
|
+
}, ms);
|
|
96
|
+
const onAbort = () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
reject(new Error('aborted'));
|
|
99
|
+
};
|
|
100
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** 构造带诊断的 Error;401/403 走专属文案,避免"重试 5 次才发现是 key 失效"。 */
|
|
104
|
+
function buildChatError(resp, body) {
|
|
105
|
+
const status = resp.status;
|
|
106
|
+
const head = `[${status} ${resp.statusText}]`;
|
|
107
|
+
if (isClientAuthError(status)) {
|
|
108
|
+
return new Error(`LLM 鉴权失败 ${head}:API key 可能失效、过期或无权访问 model "${resp.url.split('/').pop()}".\n` +
|
|
109
|
+
`请检查 .env / ~/.minimal-agent/config.json 的 MINIMAL_AGENT_API_KEY 与 provider 配置。\n` +
|
|
110
|
+
`原始错误:${body.slice(0, 500)}`);
|
|
111
|
+
}
|
|
112
|
+
return new Error(`LLM 请求失败 ${head}:${body.slice(0, 500)}`);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 把用户 signal 与超时 signal 组合,任一触发即 abort。
|
|
116
|
+
* AbortSignal.any 是 Node 20.3+ / Bun 内置,project engines.node=">=20" 满足。
|
|
117
|
+
*/
|
|
118
|
+
function combinedSignal(userSignal) {
|
|
119
|
+
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
120
|
+
if (!userSignal)
|
|
121
|
+
return timeout;
|
|
122
|
+
return AbortSignal.any([userSignal, timeout]);
|
|
123
|
+
}
|
|
43
124
|
/**
|
|
44
125
|
* 调用 LLM 并以流的形式 yield 事件。
|
|
45
126
|
*
|
|
@@ -90,7 +171,7 @@ export async function* chat(args) {
|
|
|
90
171
|
// response_format.type=json_schema,会回 400。已知不支持的 model 直接跳过,
|
|
91
172
|
// 撞到 400 则退化重试(去掉 response_format,靠 prompt 内嵌 schema + 上层 extractJsonPayload 容错解析)。
|
|
92
173
|
const rfKey = `${provider.baseURL}::${provider.model}`;
|
|
93
|
-
|
|
174
|
+
let wantResponseFormat = responseFormat !== undefined && !rfUnsupported.has(rfKey);
|
|
94
175
|
const postChat = (withResponseFormat) => fetch(url, {
|
|
95
176
|
method: 'POST',
|
|
96
177
|
headers: {
|
|
@@ -110,9 +191,11 @@ export async function* chat(args) {
|
|
|
110
191
|
// ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
|
|
111
192
|
response_format: withResponseFormat ? responseFormat : undefined,
|
|
112
193
|
}),
|
|
113
|
-
signal
|
|
194
|
+
// 用组合 signal:用户 signal 触发的 abort + 120s fetch 超时,任意一个先到即中断。
|
|
195
|
+
// (之前裸传 signal,fetch 永远不超时,gateway 桥接子进程挂死时 IM 链卡死——P0-1 根因)
|
|
196
|
+
signal: combinedSignal(signal),
|
|
114
197
|
});
|
|
115
|
-
// 3. 发请求(fetch 在 Bun/Node 20+
|
|
198
|
+
// 3. 发请求(fetch 在 Bun/Node 20+ 内置),带重试 + 超时
|
|
116
199
|
const reqId = ++reqCounter;
|
|
117
200
|
dlog('llm', `req#${reqId} POST →`, {
|
|
118
201
|
model: provider.model,
|
|
@@ -120,24 +203,89 @@ export async function* chat(args) {
|
|
|
120
203
|
msgs: cleanedMessages.length,
|
|
121
204
|
tools: openaiTools.length,
|
|
122
205
|
});
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
206
|
+
// 3a. 重试循环:每个 attempt 内部可能触发"response_format 不支持 → 去掉"的本地退化
|
|
207
|
+
// (本地退化不算重试,仍属同一 attempt 的"拿到 200"路径的一部分)
|
|
208
|
+
// 只有"网络/超时/5xx/429"才跨 attempt 重试,4xx 业务错误直接抛
|
|
209
|
+
// (401/403 走 buildChatError 的专属文案,不浪费 5 次重试)
|
|
210
|
+
let resp;
|
|
211
|
+
let lastErr;
|
|
212
|
+
for (let attempt = 1; attempt <= DEFAULT_RETRY.maxAttempts; attempt++) {
|
|
213
|
+
// 用户按 ESC/Ctrl+C 触发的 abort:立刻退出,不重试
|
|
214
|
+
if (signal?.aborted) {
|
|
215
|
+
throw new Error('aborted');
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
resp = await postChat(wantResponseFormat);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
// 1) 用户主动 abort(signal.aborted 触发 AbortError)→ 立刻抛
|
|
222
|
+
if (signal?.aborted)
|
|
223
|
+
throw new Error('aborted');
|
|
224
|
+
// 2) AbortSignal.timeout 触发的超时(Fetch 120s 还没回)
|
|
225
|
+
// 3) 其它网络错误(ECONNRESET / fetch failed / DNS 失败)→ 全部按"可重试"处理
|
|
226
|
+
const err = e;
|
|
227
|
+
lastErr = new Error(`LLM 网络错误 [${err.name ?? 'NetworkError'}]:${err.message}`);
|
|
228
|
+
dlog('llm', `req#${reqId} attempt ${attempt} failed (network):`, err.message);
|
|
229
|
+
if (attempt >= DEFAULT_RETRY.maxAttempts)
|
|
230
|
+
break;
|
|
231
|
+
const delay = Math.min(DEFAULT_RETRY.baseDelayMs * 2 ** (attempt - 1), DEFAULT_RETRY.maxDelayMs);
|
|
232
|
+
dlog('llm', `req#${reqId} retry in ${delay}ms (attempt ${attempt + 1}/${DEFAULT_RETRY.maxAttempts})`);
|
|
233
|
+
await sleep(delay, signal);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
// resp.ok = true 直接跳出循环
|
|
237
|
+
if (resp.ok)
|
|
238
|
+
break;
|
|
239
|
+
// resp !ok:先看是不是 json_schema 不支持(本地退化路径,不算重试)
|
|
126
240
|
const errText = await resp.text().catch(() => '');
|
|
127
241
|
if (wantResponseFormat && isResponseFormatUnsupported(resp.status, errText)) {
|
|
128
242
|
// 记住该 model 不支持,单进程内后续结构化调用直接跳过 response_format(不再撞 400)。
|
|
129
243
|
rfUnsupported.add(rfKey);
|
|
130
244
|
console.warn(`[minimal-agent] provider model "${provider.model}" 不支持 response_format.json_schema,` +
|
|
131
245
|
`已退化为 prompt 引导 + 容错解析(结构化输出的硬保证降级为尽力而为)。`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
246
|
+
wantResponseFormat = false; // 本 attempt 内继续,关闭 response_format
|
|
247
|
+
// 在同一 attempt 内再发一次(不计重试)
|
|
248
|
+
try {
|
|
249
|
+
resp = await postChat(false);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
// 退化二次 fetch 也网络错:把错误外提到外层重试循环
|
|
253
|
+
const err = e;
|
|
254
|
+
lastErr = new Error(`LLM 网络错误 [${err.name ?? 'NetworkError'}]:${err.message}`);
|
|
255
|
+
dlog('llm', `req#${reqId} attempt ${attempt} fallback fetch failed:`, err.message);
|
|
256
|
+
if (signal?.aborted)
|
|
257
|
+
throw new Error('aborted');
|
|
258
|
+
if (attempt >= DEFAULT_RETRY.maxAttempts)
|
|
259
|
+
break;
|
|
260
|
+
const delay = Math.min(DEFAULT_RETRY.baseDelayMs * 2 ** (attempt - 1), DEFAULT_RETRY.maxDelayMs);
|
|
261
|
+
dlog('llm', `req#${reqId} retry in ${delay}ms (attempt ${attempt + 1}/${DEFAULT_RETRY.maxAttempts})`);
|
|
262
|
+
await sleep(delay, signal);
|
|
263
|
+
continue;
|
|
136
264
|
}
|
|
265
|
+
// 退化后 OK 或 仍 !ok 都走下面统一判断
|
|
266
|
+
if (resp.ok)
|
|
267
|
+
break;
|
|
137
268
|
}
|
|
138
|
-
|
|
139
|
-
|
|
269
|
+
// 4xx 业务错误:不重试,直接抛(401/403 走 buildChatError 专属文案)
|
|
270
|
+
if (!isRetryableStatus(resp.status)) {
|
|
271
|
+
throw buildChatError(resp, errText);
|
|
140
272
|
}
|
|
273
|
+
// 5xx / 408 / 425 / 429:可重试
|
|
274
|
+
lastErr = buildChatError(resp, errText);
|
|
275
|
+
dlog('llm', `req#${reqId} attempt ${attempt} failed [${resp.status}]:`, errText.slice(0, 200));
|
|
276
|
+
if (attempt >= DEFAULT_RETRY.maxAttempts)
|
|
277
|
+
break;
|
|
278
|
+
// 429 优先用 Retry-After 头,否则走指数退避
|
|
279
|
+
const retryAfterMs = parseRetryAfter(resp);
|
|
280
|
+
const delay = retryAfterMs !== undefined
|
|
281
|
+
? retryAfterMs
|
|
282
|
+
: Math.min(DEFAULT_RETRY.baseDelayMs * 2 ** (attempt - 1), DEFAULT_RETRY.maxDelayMs);
|
|
283
|
+
dlog('llm', `req#${reqId} retry in ${delay}ms (attempt ${attempt + 1}/${DEFAULT_RETRY.maxAttempts}, retryAfter=${retryAfterMs ?? 'exp'})`);
|
|
284
|
+
await sleep(delay, signal);
|
|
285
|
+
}
|
|
286
|
+
// 5 次都没拿到 200:把最后一次的 Error 抛出去
|
|
287
|
+
if (!resp || !resp.ok) {
|
|
288
|
+
throw lastErr ?? new Error('LLM 请求失败:超过最大重试次数');
|
|
141
289
|
}
|
|
142
290
|
if (!resp.body) {
|
|
143
291
|
throw new Error('LLM 响应没有 body(provider 可能不支持流式?)');
|
package/src/loop.js
CHANGED
|
@@ -76,12 +76,7 @@ export async function* runQuery(userInput, options) {
|
|
|
76
76
|
expireOldEntries(sessionState?.microCompact);
|
|
77
77
|
if (signal?.aborted) {
|
|
78
78
|
// 中断标记帮助模型理解上下文
|
|
79
|
-
history
|
|
80
|
-
role: 'user',
|
|
81
|
-
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
82
|
-
timestamp: Date.now(),
|
|
83
|
-
id: crypto.randomUUID(),
|
|
84
|
-
});
|
|
79
|
+
pushInterruptMarker(history);
|
|
85
80
|
// code='aborted':让 `-p --output-format json` 把终结原因映射成 stop_reason='interrupted'
|
|
86
81
|
yield { type: 'error', error: '已被用户中断', code: 'aborted' };
|
|
87
82
|
return;
|
|
@@ -193,12 +188,7 @@ export async function* runQuery(userInput, options) {
|
|
|
193
188
|
catch (e) {
|
|
194
189
|
if (e.name === 'AbortError') {
|
|
195
190
|
// 用户主动中断:记录标记消息,不报红色错误
|
|
196
|
-
history
|
|
197
|
-
role: 'user',
|
|
198
|
-
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
199
|
-
timestamp: Date.now(),
|
|
200
|
-
id: crypto.randomUUID(),
|
|
201
|
-
});
|
|
191
|
+
pushInterruptMarker(history);
|
|
202
192
|
yield { type: 'interrupted' };
|
|
203
193
|
return;
|
|
204
194
|
}
|
|
@@ -395,6 +385,19 @@ export async function* runQuery(userInput, options) {
|
|
|
395
385
|
};
|
|
396
386
|
}
|
|
397
387
|
// ---------------- 辅助函数 ----------------
|
|
388
|
+
/**
|
|
389
|
+
* 把"用户按下了 ESC/Ctrl+C 中断了任务"标记消息 push 到 history。
|
|
390
|
+
* 两处使用:① loop 入口 signal 检查后 ② chat AbortError 捕获后。
|
|
391
|
+
* 抽出来消除重复——同时保证两条路径写入的文案 + shape 完全一致。
|
|
392
|
+
*/
|
|
393
|
+
function pushInterruptMarker(history) {
|
|
394
|
+
history.push({
|
|
395
|
+
role: 'user',
|
|
396
|
+
content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
|
|
397
|
+
timestamp: Date.now(),
|
|
398
|
+
id: crypto.randomUUID(),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
398
401
|
/** 把一条 tool_call_delta 合并到累积数组上 */
|
|
399
402
|
function mergeToolCallDelta(acc, ev) {
|
|
400
403
|
let slot = acc[ev.index];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ============================================================
|
|
3
|
-
* src/plugins/pluginRunner.ts —— 框架级 slash
|
|
3
|
+
* src/plugins/pluginRunner.ts —— 框架级 slash-激活分发器
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
5
|
* 通用分发器,**不含任何插件特定逻辑**。
|
|
6
6
|
*
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 2) / 输入 → discoverPlugins → resolveCommand
|
|
10
10
|
* a) 命中插件且其 plugin.ts 暴露 runCommand → 让 plugin.ts 接管
|
|
11
11
|
* b) 命中插件但只有声明式 commands/*.md → 把 prompt 拼好喂给 runQuery
|
|
12
|
-
* c) 未命中 →
|
|
12
|
+
* c) 未命中 → 静默透传 runQuery(/ 只是激活前缀,不是严格命令系统;
|
|
13
|
+
* 未知的 /xxx 当普通用户输入处理,不报错)
|
|
13
14
|
* ============================================================
|
|
14
15
|
*/
|
|
15
16
|
import { discoverPlugins, resolveCommand, buildCommandInput, } from './commandRouter.js';
|
|
@@ -28,27 +29,14 @@ export async function* runWithPlugins(userInput, options) {
|
|
|
28
29
|
});
|
|
29
30
|
return;
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
// 确保插件已发现(resolveCommand 依赖 pluginCache)
|
|
33
|
+
await discoverPlugins();
|
|
32
34
|
const matched = resolveCommand(userInput);
|
|
33
35
|
if (!matched) {
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
const cmdList = manifests.flatMap((m) => m.commands.map((c) => `/${c.name}`)).join('、');
|
|
39
|
-
yield {
|
|
40
|
-
type: 'error',
|
|
41
|
-
error: `未找到命令 "${cmdName}"。已安装的插件命令:${cmdList || '(无)'}。`,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
// 无任何插件 → 告知用户
|
|
46
|
-
yield {
|
|
47
|
-
type: 'error',
|
|
48
|
-
error: `当前未安装任何插件。命令 "${cmdName}" 无法执行。`,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
// fallback 到普通 TAOR 对话,用户可继续操作
|
|
36
|
+
// 未命中任何插件命令 → 静默透传 runQuery。
|
|
37
|
+
// / 只是激活前缀(类似 Claude Code 的 / 模式),不是严格命令系统。
|
|
38
|
+
// 用户可能输入 /init、/help、/任何东西 —— 不匹配就当普通对话处理,
|
|
39
|
+
// 让 LLM 自行理解意图。不报错、不提示、不打断用户体验。
|
|
52
40
|
yield* runQuery(userInput, {
|
|
53
41
|
provider,
|
|
54
42
|
history,
|
|
@@ -28,17 +28,17 @@ export async function getPluginMetaList() {
|
|
|
28
28
|
*
|
|
29
29
|
* 风格对齐 skillList.formatSkillHint():
|
|
30
30
|
* - 每个插件固定格式
|
|
31
|
-
* - 明确标注触发方式("
|
|
31
|
+
* - 明确标注触发方式("由用户通过 /xxx 激活")
|
|
32
32
|
* - 无插件时返回空字符串(调用方自行决定是否追加)
|
|
33
33
|
*/
|
|
34
34
|
export function formatPluginHint(plugins) {
|
|
35
35
|
if (plugins.length === 0)
|
|
36
36
|
return '';
|
|
37
37
|
const lines = [
|
|
38
|
-
'#
|
|
38
|
+
'# 可用插件(/激活)',
|
|
39
39
|
'',
|
|
40
|
-
'以下是当前已安装的插件。**插件只能由用户通过 /xxx
|
|
41
|
-
'只有当用户明确询问"
|
|
40
|
+
'以下是当前已安装的插件。**插件只能由用户通过 /xxx 激活——你绝对不能代替用户执行、模拟或建议使用插件功能**。',
|
|
41
|
+
'只有当用户明确询问"有哪些可用插件/激活入口"时,才列出以下内容。其他时候不要提及插件。',
|
|
42
42
|
'',
|
|
43
43
|
];
|
|
44
44
|
for (const p of plugins) {
|
|
@@ -46,7 +46,7 @@ export function formatPluginHint(plugins) {
|
|
|
46
46
|
lines.push(`- **描述**: ${p.description}`);
|
|
47
47
|
if (p.commands.length > 0) {
|
|
48
48
|
const cmdList = p.commands.map((c) => `\`/${c.name}\``).join('、');
|
|
49
|
-
lines.push(`-
|
|
49
|
+
lines.push(`- **激活**: ${cmdList}`);
|
|
50
50
|
}
|
|
51
51
|
lines.push('');
|
|
52
52
|
}
|
|
@@ -65,15 +65,15 @@ const CORE_IDENTITY = `# 思维与立场(核心:像专家一样独立思考
|
|
|
65
65
|
3. **判不准边界就先确认**:一句话澄清"你是继续刚才的 X,还是开始新的?",别默默假设。
|
|
66
66
|
- 当前用户消息 + 紧邻的最近几条消息,权重永远高于更早历史;越早、越不相关的内容参考价值越低。
|
|
67
67
|
|
|
68
|
-
#
|
|
69
|
-
- /plan、/workflow
|
|
68
|
+
# 上下文隔离(重要!工作流/激活执行完毕后的行为规则)
|
|
69
|
+
- /plan、/workflow 等激活执行完后**自动回到普通对话模式**——下一条用户输入默认是全新任务。
|
|
70
70
|
- **只有当用户明确提到延续意图时**("继续"、"接着做"、"按照计划执行"、"读一下 PLAN.md"、"修改刚才的结果"),才去关联之前的工作流产物(PLAN.md、生成的文件等)。
|
|
71
71
|
- 用户提出全新任务时(如"帮我写个函数"、"这个bug怎么修"、"review 下代码"),**不要主动 Read 之前的 PLAN.md 或引用上次工作流的结果**——那是上一次的产出,与当前任务无关。
|
|
72
72
|
- 当前目录可能存在之前 /plan 留下的 PLAN.md 文件:判断标准是**用户消息是否包含对之前产出的引用**?没有就当新任务处理。
|
|
73
73
|
- 如果你不确定是新任务还是延续,优先按**新任务**处理——宁可多问一句也不要用旧上下文污染新工作。
|
|
74
74
|
|
|
75
75
|
# 中断后调整(重要!)
|
|
76
|
-
- 用户可能在上一步 /plan 或 /workflow
|
|
76
|
+
- 用户可能在上一步 /plan 或 /workflow 激活中途按 ESC/Ctrl+C 中断,然后立即输入调整指令(如"把第3步改成xxx"、"换个方向重新计划")。
|
|
77
77
|
- **检测方法**:如果用户消息包含调整/修改/重做/换方向 等词 + 历史中有 /plan 或 /workflow 的痕迹 → 这是**中断后调整**,不是全新任务。
|
|
78
78
|
- **小调整处理**:先 Read ./PLAN.md 了解当前计划状态,然后直接在当前 TAOR 对话中完成修改(Edit 写入 PLAN.md 或用工具产出新文件)。不需要重新跑 /plan 工作流。
|
|
79
79
|
- **大改/重做处理**:如果用户要求完全重做或换方向,建议用户用 '/plan 新目标' 重新跑完整流程——这会覆盖旧的 PLAN.md 并从头开始。
|
|
@@ -206,38 +206,3 @@ export async function assembleBasePrompt(options) {
|
|
|
206
206
|
}
|
|
207
207
|
return parts.join('\n\n');
|
|
208
208
|
}
|
|
209
|
-
/**
|
|
210
|
-
* 只生成 Layer 3(skill 列表)+ Layer 4(plugin 列表)的轻量提示词。
|
|
211
|
-
*
|
|
212
|
-
* 用于 workflow 的 llm 步骤(tools=[],不能执行工具但需要知道能力边界,
|
|
213
|
-
* 以便在输出文本中引用可用的 skill/plugin)。
|
|
214
|
-
*/
|
|
215
|
-
export async function assembleCapabilityHint(cwd) {
|
|
216
|
-
const parts = [];
|
|
217
|
-
let hasContent = false;
|
|
218
|
-
// Layer 3: Skill 目录
|
|
219
|
-
const skills = await getSkillList();
|
|
220
|
-
const skillHint = formatSkillHint(skills);
|
|
221
|
-
if (skillHint) {
|
|
222
|
-
parts.push(skillHint);
|
|
223
|
-
hasContent = true;
|
|
224
|
-
}
|
|
225
|
-
// Layer 4: Plugin 目录(可选增量,异常时跳过)
|
|
226
|
-
try {
|
|
227
|
-
const plugins = await getPluginMetaList();
|
|
228
|
-
const pluginHint = formatPluginHint(plugins);
|
|
229
|
-
if (pluginHint) {
|
|
230
|
-
parts.push(pluginHint);
|
|
231
|
-
hasContent = true;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
catch {
|
|
235
|
-
// 插件扫描失败不影响 skill hint
|
|
236
|
-
}
|
|
237
|
-
if (!hasContent)
|
|
238
|
-
return '';
|
|
239
|
-
return ('\n\n--- 能力参考 ---\n' +
|
|
240
|
-
'以下是你可用的技能和插件(本步骤不能直接调用工具,但编写计划/回答时应考虑它们的存在,必要时建议用户后续使用):\n' +
|
|
241
|
-
parts.join('\n\n') +
|
|
242
|
-
'\n');
|
|
243
|
-
}
|
package/src/prompts/skillList.js
CHANGED
|
@@ -91,7 +91,7 @@ export function formatSkillHint(skills) {
|
|
|
91
91
|
}
|
|
92
92
|
const lines = [];
|
|
93
93
|
// ---- 标题段 ----
|
|
94
|
-
lines.push('#
|
|
94
|
+
lines.push('# 可用技能(/激活)');
|
|
95
95
|
lines.push('');
|
|
96
96
|
lines.push('以下是当前可用的技能(**双源**:用户项目目录 + minimal-agent 包目录,同名时项目优先)。**当用户需求与某个技能的功能描述匹配时,你应该主动使用它**。');
|
|
97
97
|
lines.push('在使用前,先用 Read 工具读取下方每个技能卡片里的 `**技能目录**` 路径 + `/SKILL.md` 了解完整流程。');
|
package/src/prompts/system.js
CHANGED
package/src/ui/AskQuestion.js
CHANGED
|
@@ -21,10 +21,27 @@ import { useState } from 'react';
|
|
|
21
21
|
import { Box, Text, useInput } from 'ink';
|
|
22
22
|
/** description 过长截断到此长度,防止 live 区被超长文本撑爆。 */
|
|
23
23
|
const DESC_MAX = 80;
|
|
24
|
+
/**
|
|
25
|
+
* 提问上限——与 system prompt 里的"最多 4 题"硬性对齐。超出会:
|
|
26
|
+
* - UI 截断到 4 题(仅前 4 题可答,后面的被静默丢弃)
|
|
27
|
+
* - stderr 注记,提示上游(callers 应在调 AskUserQuestion 工具前自检)
|
|
28
|
+
* 这是防御层——理想情况是 tool 上游 Zod 校验就拦住。
|
|
29
|
+
*/
|
|
30
|
+
const MAX_QUESTIONS = 4;
|
|
24
31
|
function clampDesc(s) {
|
|
25
32
|
return s.length > DESC_MAX ? `${s.slice(0, DESC_MAX - 1)}…` : s;
|
|
26
33
|
}
|
|
27
34
|
export function AskQuestion({ questions, onAnswer, onCancel, }) {
|
|
35
|
+
// 硬限 4 题;超量截断并注记
|
|
36
|
+
const effectiveQuestions = questions.length > MAX_QUESTIONS
|
|
37
|
+
? (() => {
|
|
38
|
+
try {
|
|
39
|
+
process.stderr.write(`[AskUserQuestion] 收到 ${questions.length} 题超过上限 ${MAX_QUESTIONS},已截断\n`);
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore */ }
|
|
42
|
+
return questions.slice(0, MAX_QUESTIONS);
|
|
43
|
+
})()
|
|
44
|
+
: questions;
|
|
28
45
|
// 当前进行到第几题
|
|
29
46
|
const [questionIndex, setQuestionIndex] = useState(0);
|
|
30
47
|
// 当前题高亮的选项下标(含末尾「其他」伪选项)
|
|
@@ -37,7 +54,7 @@ export function AskQuestion({ questions, onAnswer, onCancel, }) {
|
|
|
37
54
|
const [textMode, setTextMode] = useState(false);
|
|
38
55
|
// 文本子模式的累积草稿
|
|
39
56
|
const [draft, setDraft] = useState('');
|
|
40
|
-
const current =
|
|
57
|
+
const current = effectiveQuestions[questionIndex];
|
|
41
58
|
const multi = current?.multiSelect === true;
|
|
42
59
|
const options = current?.options ?? [];
|
|
43
60
|
// 末尾「其他」伪选项占据下标 options.length,故列表总长 = options.length + 1
|
|
@@ -47,7 +64,7 @@ export function AskQuestion({ questions, onAnswer, onCancel, }) {
|
|
|
47
64
|
function advance(answer) {
|
|
48
65
|
const nextAnswers = [...answers, answer];
|
|
49
66
|
const nextQ = questionIndex + 1;
|
|
50
|
-
if (nextQ >=
|
|
67
|
+
if (nextQ >= effectiveQuestions.length) {
|
|
51
68
|
onAnswer(nextAnswers);
|
|
52
69
|
return;
|
|
53
70
|
}
|
|
@@ -166,7 +183,7 @@ export function AskQuestion({ questions, onAnswer, onCancel, }) {
|
|
|
166
183
|
if (!current) {
|
|
167
184
|
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "\uFF08\u65E0\u95EE\u9898\uFF09" }) }));
|
|
168
185
|
}
|
|
169
|
-
const progress = `提问 (${questionIndex + 1}/${
|
|
186
|
+
const progress = `提问 (${questionIndex + 1}/${effectiveQuestions.length}) · ${current.header}`;
|
|
170
187
|
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "cyan", bold: true, children: progress }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: current.question }) }), textMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "\u270F\uFE0F \u81EA\u7531\u8F93\u5165\uFF1A" }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: _jsx(Text, { children: draft || ' ' }) }), _jsx(Text, { color: "gray", children: "\u8F93\u5165\u540E Enter \u63D0\u4EA4 \u00B7 ESC \u8FD4\u56DE" })] })) : (_jsxs(Box, { flexDirection: "column", children: [options.map((opt, i) => {
|
|
171
188
|
const active = i === idx;
|
|
172
189
|
const box = multi ? (picked.has(i) ? '[x] ' : '[ ] ') : '';
|