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/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
- **11 个内置工具**:Read / Write / Edit / MultiEdit / Glob / Grep / WebSearch / WebFetch / WebBrowser / Bash / AskUserQuestion(向用户提结构化选择题,动态工作流交互底座)
|
|
26
26
|
- **OpenAI 兼容**:Message 形状对齐 Chat Completions,一套代码接 MiniMax / 火山 ARK / DeepSeek / OpenAI / Moonshot…
|
|
27
27
|
- **Skills 系统**:放一个 `SKILL.md` 就能扩展能力,运行时实时扫描注入 prompt,改文件即生效
|
|
28
|
-
- **插件系统**:`plugins/<id>/` drop-in,自带 `/
|
|
28
|
+
- **插件系统**:`plugins/<id>/` drop-in,自带 `/workflow`(YAML 工作流引擎,支持 while 循环 + 验证门 + fresh_context)
|
|
29
29
|
- **自动上下文压缩**:接近上限时把旧对话压成摘要,保留系统提示 + 摘要 + 最近几条
|
|
30
30
|
- **多 session + 归档**:同一工作目录多个并行会话,`/new` 归档、`/restore` 恢复
|
|
31
31
|
- **可编程子进程**:`-p --output-format json` 输出一行结构化结局契约,方便被宿主程序 spawn 调度
|
|
@@ -98,7 +98,7 @@ bun run start # 或全局安装后直接 minimal-agent
|
|
|
98
98
|
| `/show <id>` | 展开某次工具调用的完整输出(列表默认折叠到 3 行) |
|
|
99
99
|
| `/exit`(`/quit`) | 退出 |
|
|
100
100
|
|
|
101
|
-
`/init`、`/config` 等是 **LLM 驱动的 skill**;`/workflow`、`/
|
|
101
|
+
`/init`、`/config` 等是 **LLM 驱动的 skill**;`/workflow`、`/workflows` 是**插件激活入口**(见下)。
|
|
102
102
|
|
|
103
103
|
### 非交互模式(`-p`)—— 脚本 / CI / 被宿主程序调用
|
|
104
104
|
|
|
@@ -152,8 +152,7 @@ minimal-agent -p --output-format json "做点事" # 一行结构化结
|
|
|
152
152
|
- **Skills**:每个 `skills/<name>/SKILL.md`(带 frontmatter)。运行时实时扫描注入 prompt,prompt 体按需懒加载。
|
|
153
153
|
cwd 优先、包内 fallback,用户可在自己项目里覆盖或新增。内置 20+ 个(`/init`、`/config`、文档生成、设计等)。
|
|
154
154
|
- **插件**(`plugins/<id>/`,drop-in,`src/` 一行不改):
|
|
155
|
-
- **`/
|
|
156
|
-
- **`/workflow` · `/workflows`**(workflow-runner)—— YAML DAG 执行器,9 种 step(tool/llm/skill/assert/branch/loop/pause/parallel/vote)
|
|
155
|
+
- **`/workflow` · `/workflows`**(workflow-runner)—— YAML 工作流引擎,10 种 step(tool/llm/skill/assert/agent/branch/loop/break/pause/parallel/vote),支持 while 循环 + fresh_context + 验证门。内置 `ralph-loop` 等工作流定义
|
|
157
156
|
- **可视化编辑器**:`editor/` 子项目(Bun + Vite + React Flow)拖拽编排 workflow YAML(不属于 npm 发布物)。
|
|
158
157
|
|
|
159
158
|
---
|
|
@@ -215,7 +214,7 @@ dev 下 Bun 透明回退到 `.ts` 兄弟,发布前 `tsc --build` 原地产出
|
|
|
215
214
|
minimal-agent/
|
|
216
215
|
├─ src/ # 主程序(loop / tools / llm / ui / plugins / config …)
|
|
217
216
|
├─ skills/ # 内置 skill(每个一个 SKILL.md)
|
|
218
|
-
├─ plugins/ # 内置插件(
|
|
217
|
+
├─ plugins/ # 内置插件(workflow-runner)
|
|
219
218
|
├─ workflows/ # 内置 workflow(YAML)
|
|
220
219
|
├─ vendor/ripgrep/ # Grep 自带的 6 平台 ripgrep 二进制
|
|
221
220
|
├─ scripts/ # 构建脚本(含绿色版 build-green.ts)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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>",
|
|
@@ -1,55 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ============================================================
|
|
3
|
-
* plugins/workflow-runner/src/agentCommon.ts —— workflow 内「有界 agent
|
|
3
|
+
* plugins/workflow-runner/src/agentCommon.ts —— workflow 内「有界 agent」的共享工具
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
|
-
* 工作流里有两处会临时起一个 agent
|
|
5
|
+
* 工作流里有两处会临时起一个 agent(runQuery 执行后的验证闸门):
|
|
6
6
|
* - onError:'recover'(recovery.ts)—— 节点失败后救火
|
|
7
7
|
* - agent: 节点(stepExecutors/agent.ts)—— 作者显式声明的开放任务
|
|
8
8
|
* 二者本质同构:受工作流目标/上下文指导、用工具随机应变、产出经验证后回灌 capture。
|
|
9
9
|
* 为避免「同一个脑子写两份提示词、行为漂移」,这里收敛它们的**共享件**:
|
|
10
|
-
* 1.
|
|
11
|
-
* 两处只在前面各加一句**角色开场白**(recover=救火 / agent=自主完成一步),
|
|
12
|
-
* 后面追加共享纪律块。
|
|
13
|
-
* 2. 统一验证闸门(interpretMiniReActResult)—— miniReAct 跑完后,判定「真交付」还是
|
|
10
|
+
* 1. 统一验证闸门(interpretMiniReActResult)—— runQuery 执行完后,判定「真交付」还是
|
|
14
11
|
* 「放弃/不可信」,**永不把没验证过的产出当成功**。这是 Phase 2 验证闸门的提取。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ GIVEUP 标记设计原则(v0.9.3 修复 LLM 误触发问题):
|
|
14
|
+
* 旧标记 __AGENT_GIVEUP__ 使用 __XXX__ 格式,MiniMax/GPT 等模型容易在输出中
|
|
15
|
+
* 幻觉出类似标记(如 "__AGENT_GIVEUP__: since I did the work..."),导致已完成的工作
|
|
16
|
+
* 被误判为"放弃"而 halt。新标记采用三重防误触发设计:
|
|
17
|
+
* ① 非标准格式(非 __XXX__,LLM 训练数据中几乎不存在)
|
|
18
|
+
* ② 含明确的 "DO-NOT-GENERATE" 警告文本
|
|
19
|
+
* ③ 检测逻辑增加内容长度启发式(reason > 80 字符视为误报)
|
|
15
20
|
* ============================================================
|
|
16
21
|
*/
|
|
17
|
-
import { assembleBasePrompt } from '../../../src/prompts/promptAssembly.js';
|
|
18
|
-
/** agent 显式放弃的唯一标记;与 DISCIPLINE 收尾约束严格对应。recover 与 agent 节点共用。 */
|
|
19
|
-
export const GIVEUP_MARKER = '__AGENT_GIVEUP__';
|
|
20
|
-
/** agent 步骤的角色开场白(与 recovery 的 RECOVER_OPENING 对称) */
|
|
21
|
-
export const AGENT_OPENING = '你是一名资深工程师,被一个确定性工作流临时调来「自主完成一步开放任务」。';
|
|
22
|
-
/** 共享纪律块:所有 workflow 内 agent 的通用行为约束(角色开场白由各调用方拼在前面)。 */
|
|
23
|
-
const DISCIPLINE = `按这个纪律干活:
|
|
24
|
-
1. 先想清楚「这一步要产出什么」,必要时用只读工具(Read / Grep / Glob)做最小探查,别凭空猜、别凭空编。
|
|
25
|
-
2. 用工具**实际去做**(读文件、执行命令、写文件…),不要只描述计划。
|
|
26
|
-
3. **收尾前必须自我验证**:用工具确认产出确实存在且满足意图(写了文件就 Read 回来核对、跑了命令就检查退出码与输出)。没验证过不算完成。
|
|
27
|
-
|
|
28
|
-
安全护栏(你握有包括 Bash 在内的全部工具,务必克制):
|
|
29
|
-
- 只做达成意图所必需的最小动作,优先可逆操作。
|
|
30
|
-
- 严禁破坏性行为:不 rm -rf、不删除/覆盖与本步无关的用户数据、不碰工作目录以外的东西。
|
|
31
|
-
- **绝不靠伪造、占位、或编造一段「看起来对」的文本来假装成功**——你的产出会被工作流绑给后续步骤,假结果比诚实失败更糟。
|
|
32
|
-
|
|
33
|
-
收尾(二选一,硬约束):
|
|
34
|
-
- 真的达成了 → 最终消息**只输出这一步要的结果本身**,不要寒暄、不要复述要求、不要解释推理过程。若要求结构化输出,严格按 JSON Schema、**不要**用 markdown 代码块包裹。
|
|
35
|
-
- 确实做不成 → 最终消息**只输出一行**:\`${GIVEUP_MARKER}: <一句话说明为什么>\`,让工作流诚实地停下。绝不要为了「交差」而编一个假结果。`;
|
|
36
22
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* 角色开场白 + 基础内核(含 skill/plugin/工具完整列表) + 共享纪律
|
|
23
|
+
* agent 显式放弃的唯一标记;recover 与 agent 节点共用。
|
|
40
24
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
25
|
+
* 设计约束(纯 ASCII,避免 Unicode 编码匹配问题):
|
|
26
|
+
* - 不使用 __XXX__ 格式(LLM 容易幻觉模仿)
|
|
27
|
+
* - 含 "wf-internal" 前缀 + "do-not-generate" 明确警告
|
|
28
|
+
* - 使用 [bracket] 格式而非 ⟨angle⟩(避免 Unicode 字符在 Bun indexOf 中行为不一致)
|
|
43
29
|
*/
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
tools,
|
|
48
|
-
includeProjectInstructions: false, // workflow 子步骤不需要项目指令
|
|
49
|
-
includePlugins: true, // 插件列表对 agent 也有参考价值
|
|
50
|
-
});
|
|
51
|
-
return `${opening}\n\n${baseKernel}\n\n${DISCIPLINE}`;
|
|
52
|
-
}
|
|
30
|
+
export const GIVEUP_MARKER = '[wf-internal-abort-do-not-generate]';
|
|
31
|
+
/** agent 步骤的角色开场白(与 recovery 的 RECOVER_OPENING 对称) */
|
|
32
|
+
export const AGENT_OPENING = '你是一名资深工程师,被一个确定性工作流临时调来「自主完成一步开放任务」。';
|
|
53
33
|
/** 把 step.capture 翻成「下游会把你的结果存成哪些变量」,让 agent 产出对得上形状。 */
|
|
54
34
|
export function describeCaptureContract(step) {
|
|
55
35
|
if (!step.capture)
|
|
@@ -64,26 +44,60 @@ export function truncate(s, n) {
|
|
|
64
44
|
}
|
|
65
45
|
/**
|
|
66
46
|
* 解析非 schema 模式下 agent 最终文本里的放弃标记。
|
|
67
|
-
* - 命中
|
|
47
|
+
* - 命中 GIVEUP_MARKER → 返回原因字符串(可能为空串)
|
|
68
48
|
* - 未命中 → null(视为给出了真实结果)
|
|
49
|
+
*
|
|
50
|
+
* 注意:旧标记 __AGENT_GIVEUP__ 也兼容检测(向后兼容已存在的 checkpoint/状态数据),
|
|
51
|
+
* 但新代码只使用新标记格式。
|
|
69
52
|
*/
|
|
53
|
+
const LEGACY_GIVEUP_MARKER = '__AGENT_GIVEUP__';
|
|
70
54
|
export function parseGiveup(text) {
|
|
71
|
-
|
|
55
|
+
// 优先检测新标记
|
|
56
|
+
const newIdx = text.indexOf(GIVEUP_MARKER);
|
|
57
|
+
const legacyIdx = text.indexOf(LEGACY_GIVEUP_MARKER);
|
|
58
|
+
// 取先出现的那一个
|
|
59
|
+
let idx = -1;
|
|
60
|
+
let markerLen = 0;
|
|
61
|
+
if (newIdx !== -1 && (legacyIdx === -1 || newIdx < legacyIdx)) {
|
|
62
|
+
idx = newIdx;
|
|
63
|
+
markerLen = GIVEUP_MARKER.length;
|
|
64
|
+
}
|
|
65
|
+
else if (legacyIdx !== -1) {
|
|
66
|
+
idx = legacyIdx;
|
|
67
|
+
markerLen = LEGACY_GIVEUP_MARKER.length;
|
|
68
|
+
}
|
|
72
69
|
if (idx === -1)
|
|
73
70
|
return null;
|
|
74
71
|
return text
|
|
75
|
-
.slice(idx +
|
|
76
|
-
.replace(/^[::\s]+/, '')
|
|
72
|
+
.slice(idx + markerLen)
|
|
73
|
+
.replace(/^[::\s\]]+/, '') // 兼容新旧标记后的分隔符(: / : / ])
|
|
77
74
|
.trim();
|
|
78
75
|
}
|
|
76
|
+
/** 判断一个 giveup reason 是否看起来像"误报"(实际是 LLM 在正常输出中碰巧触发了标记)。
|
|
77
|
+
*
|
|
78
|
+
* 启发式规则:
|
|
79
|
+
* - reason 长度 > 80 字符 → 几乎可以确定是误报(真正的放弃原因通常简短)
|
|
80
|
+
* - reason 包含结构化内容(如 ## 标题、### 章节、列表项等)→ 误报
|
|
81
|
+
*/
|
|
82
|
+
function isLikelyFalsePositive(reason) {
|
|
83
|
+
if (reason.length > 80)
|
|
84
|
+
return true;
|
|
85
|
+
// 如果 reason 看起来像 markdown 正文(标题/章节),也是误报
|
|
86
|
+
if (/^#{1,6}\s/m.test(reason) || /^[-*+]\s/m.test(reason) || /^\d+\.\s/m.test(reason)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
79
91
|
/**
|
|
80
|
-
* 验证闸门:把
|
|
81
|
-
* - schema 模式:
|
|
92
|
+
* 验证闸门:把 StepResult 判定为「真交付」或「放弃/不可信」。
|
|
93
|
+
* - schema 模式:extractJsonPayload 已严格解析(解析失败会抛、落不到这里),能拿到结果即 ok;
|
|
82
94
|
* 补 content 别名不覆盖具名字段。
|
|
83
95
|
* - 非 schema 模式:空产出 / 命中 GIVEUP 标记 → ok:false;否则 ok:true,raw 补 text/result/content
|
|
84
96
|
* 三键别名,兼容 capture:{content:x}/{result:x}/{text:x}。
|
|
85
|
-
*
|
|
86
|
-
*
|
|
97
|
+
*
|
|
98
|
+
* v0.9.3 增强:GIVEUP 检测增加误报过滤(isLikelyFalsePositive)。
|
|
99
|
+
* 当标记后的内容看起来像是实质性的工作产出而非简短放弃理由时,
|
|
100
|
+
* 视为 LLM 误触发标记 → 仍然当作成功交付处理。
|
|
87
101
|
*/
|
|
88
102
|
export function interpretMiniReActResult(result, hasSchema) {
|
|
89
103
|
if (hasSchema) {
|
|
@@ -100,6 +114,27 @@ export function interpretMiniReActResult(result, hasSchema) {
|
|
|
100
114
|
}
|
|
101
115
|
const giveup = parseGiveup(trimmed);
|
|
102
116
|
if (giveup !== null) {
|
|
117
|
+
// v0.9.3: 误报过滤 —— 如果 "放弃原因" 太长或太结构化,
|
|
118
|
+
// 说明 LLM 是在正常输出中碰巧触发了标记,不应判为放弃
|
|
119
|
+
if (isLikelyFalsePositive(giveup)) {
|
|
120
|
+
// 降级为成功交付:去掉误触发的标记部分,保留实质内容
|
|
121
|
+
const cleanText = trimmed
|
|
122
|
+
.replace(GIVEUP_MARKER, '')
|
|
123
|
+
.replace(LEGACY_GIVEUP_MARKER, '')
|
|
124
|
+
.trim();
|
|
125
|
+
const raw = {
|
|
126
|
+
text: cleanText,
|
|
127
|
+
result: cleanText,
|
|
128
|
+
content: cleanText,
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
value: {
|
|
133
|
+
raw,
|
|
134
|
+
preview: cleanText.length > 200 ? `${cleanText.slice(0, 200)}…` : cleanText,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
103
138
|
return { ok: false, reason: giveup || '显式放弃(未给原因)' };
|
|
104
139
|
}
|
|
105
140
|
const raw = {
|
|
@@ -40,7 +40,7 @@ import { existsSync } from 'node:fs';
|
|
|
40
40
|
import { join, isAbsolute } from 'node:path';
|
|
41
41
|
import { getWorkingDir } from '../../../src/plugin-sdk.js';
|
|
42
42
|
const PUNCT2 = new Set(['==', '!=', '>=', '<=', '&&', '||']);
|
|
43
|
-
const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<', '+']);
|
|
43
|
+
const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<', '+', '?', ':']);
|
|
44
44
|
function tokenize(src) {
|
|
45
45
|
const out = [];
|
|
46
46
|
let i = 0;
|
|
@@ -164,6 +164,37 @@ const BUILTINS = {
|
|
|
164
164
|
upper(args) {
|
|
165
165
|
return String(args[0] ?? '').toUpperCase();
|
|
166
166
|
},
|
|
167
|
+
includes(args) {
|
|
168
|
+
const haystack = String(args[0] ?? '');
|
|
169
|
+
const needle = String(args[1] ?? '');
|
|
170
|
+
return haystack.includes(needle);
|
|
171
|
+
},
|
|
172
|
+
startsWith(args) {
|
|
173
|
+
const s = String(args[0] ?? '');
|
|
174
|
+
const prefix = String(args[1] ?? '');
|
|
175
|
+
return s.startsWith(prefix);
|
|
176
|
+
},
|
|
177
|
+
endsWith(args) {
|
|
178
|
+
const s = String(args[0] ?? '');
|
|
179
|
+
const suffix = String(args[1] ?? '');
|
|
180
|
+
return s.endsWith(suffix);
|
|
181
|
+
},
|
|
182
|
+
match(args) {
|
|
183
|
+
const s = String(args[0] ?? '');
|
|
184
|
+
const pattern = String(args[1] ?? '');
|
|
185
|
+
try {
|
|
186
|
+
const re = new RegExp(pattern);
|
|
187
|
+
return re.test(s);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false; // 非法正则 → 不匹配
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
split(args) {
|
|
194
|
+
const s = String(args[0] ?? '');
|
|
195
|
+
const delim = args[1] !== undefined ? String(args[1]) : ',';
|
|
196
|
+
return s.split(delim);
|
|
197
|
+
},
|
|
167
198
|
};
|
|
168
199
|
function parseOr(c, vars) {
|
|
169
200
|
let left = parseAnd(c, vars);
|
|
@@ -174,6 +205,21 @@ function parseOr(c, vars) {
|
|
|
174
205
|
}
|
|
175
206
|
return left;
|
|
176
207
|
}
|
|
208
|
+
// 三元表达式:cond ? true_expr : false_expr
|
|
209
|
+
// 优先级低于 ||(即先算完 || 再判断 ? :)
|
|
210
|
+
// 右结合:a ? b ? c : d : e 等价于 a ? (b ? c : d) : e
|
|
211
|
+
function parseTernary(c, vars) {
|
|
212
|
+
const cond = parseOr(c, vars);
|
|
213
|
+
if (!isPunct(c, '?'))
|
|
214
|
+
return cond;
|
|
215
|
+
eat(c); // 吃掉 '?'
|
|
216
|
+
const consequent = parseTernary(c, vars); // 右结合:true 分支可以是嵌套三元
|
|
217
|
+
if (!isPunct(c, ':'))
|
|
218
|
+
throw new Error("三元表达式缺少 ':'");
|
|
219
|
+
eat(c); // 吃掉 ':'
|
|
220
|
+
const alternate = parseTernary(c, vars); // 右结合:false 分支也可以是嵌套三元
|
|
221
|
+
return truthy(cond) ? consequent : alternate;
|
|
222
|
+
}
|
|
177
223
|
function parseAnd(c, vars) {
|
|
178
224
|
let left = parseNot(c, vars);
|
|
179
225
|
while (isPunct(c, '&&')) {
|
|
@@ -349,7 +395,7 @@ export function evalExpr(expr, vars) {
|
|
|
349
395
|
if (toks.length === 0)
|
|
350
396
|
return undefined;
|
|
351
397
|
const cursor = { toks, pos: 0 };
|
|
352
|
-
const v =
|
|
398
|
+
const v = parseTernary(cursor, vars);
|
|
353
399
|
if (cursor.pos !== toks.length) {
|
|
354
400
|
throw new Error(`表达式多余 token: "${expr}"`);
|
|
355
401
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 破坏主循环。剥离必须发生在「契约要求纯 JSON」的边界,也就是 workflow 的
|
|
11
11
|
* output_schema 解析处。本模块就是那个边界的唯一实现,被两个解析点共享:
|
|
12
12
|
* - stepExecutors/llm.ts(llm + output_schema 单轮)
|
|
13
|
-
* -
|
|
13
|
+
* - stepExecutors/llm.ts(output_schema 解析处)
|
|
14
14
|
* 放一处、两边 import,杜绝"只修一个解析点、另一个继续炸"的漂移。
|
|
15
15
|
*
|
|
16
16
|
* 处理顺序(保守、纯函数、可单测):
|
|
@@ -103,6 +103,29 @@ function validate(obj, file) {
|
|
|
103
103
|
throw new WorkflowLoadError("缺少必填字段 'steps' 或为空数组", file);
|
|
104
104
|
}
|
|
105
105
|
validateSteps(obj.steps, file, '');
|
|
106
|
+
// self_heal 校验(可选)
|
|
107
|
+
if (obj.self_heal !== undefined) {
|
|
108
|
+
const sh = obj.self_heal;
|
|
109
|
+
if (typeof sh !== 'object' || sh === null || Array.isArray(sh)) {
|
|
110
|
+
throw new WorkflowLoadError("'self_heal' 必须是对象", file);
|
|
111
|
+
}
|
|
112
|
+
if (sh.backoff !== undefined) {
|
|
113
|
+
if (typeof sh.backoff !== 'number' || !Number.isInteger(sh.backoff) || sh.backoff < 0) {
|
|
114
|
+
throw new WorkflowLoadError("self_heal.backoff 必须是 >= 0 的整数", file);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (sh.max_retries !== undefined) {
|
|
118
|
+
if (typeof sh.max_retries !== 'number' || !Number.isInteger(sh.max_retries) || sh.max_retries < 1) {
|
|
119
|
+
throw new WorkflowLoadError("self_heal.max_retries 必须是 >= 1 的整数", file);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// artifacts 校验(WorkflowDef 级别,可选)
|
|
124
|
+
if (obj.artifacts !== undefined) {
|
|
125
|
+
if (!Array.isArray(obj.artifacts) || !obj.artifacts.every((x) => typeof x === 'string' && x.length > 0)) {
|
|
126
|
+
throw new WorkflowLoadError("'artifacts' 必须是非空字符串数组", file);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
106
129
|
if (obj.inputs !== undefined) {
|
|
107
130
|
if (!Array.isArray(obj.inputs)) {
|
|
108
131
|
throw new WorkflowLoadError("'inputs' 必须是数组", file);
|
|
@@ -133,6 +156,7 @@ export const CONTROL_FLOW_TYPES = new Set([
|
|
|
133
156
|
'verify',
|
|
134
157
|
'branch',
|
|
135
158
|
'loop',
|
|
159
|
+
'break',
|
|
136
160
|
'pause',
|
|
137
161
|
'parallel',
|
|
138
162
|
'vote',
|
|
@@ -142,6 +166,7 @@ export const VALID_STEP_TYPES = new Set([
|
|
|
142
166
|
'verify',
|
|
143
167
|
'branch',
|
|
144
168
|
'loop',
|
|
169
|
+
'break',
|
|
145
170
|
'pause',
|
|
146
171
|
'parallel',
|
|
147
172
|
'vote',
|
|
@@ -196,24 +221,47 @@ function validateSteps(steps, file, pathPrefix) {
|
|
|
196
221
|
throw new WorkflowLoadError(`${p} type=branch 必须带 'condition'`, file);
|
|
197
222
|
}
|
|
198
223
|
if (s.type === 'loop') {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
224
|
+
const hasOver = s.over !== undefined;
|
|
225
|
+
const hasMaxIter = s.max_iterations !== undefined;
|
|
226
|
+
// 两种模式互斥:for-each(over 必填)或 while(max_iterations 必填)
|
|
227
|
+
if (!hasOver && !hasMaxIter) {
|
|
228
|
+
throw new WorkflowLoadError(`${p} type=loop: for-each 模式需 'over',while 模式需 'max_iterations',二者必选其一`, file);
|
|
229
|
+
}
|
|
230
|
+
if (hasOver) {
|
|
231
|
+
// for-each 模式
|
|
232
|
+
if (typeof s.as !== 'string' || s.as.length === 0) {
|
|
233
|
+
throw new WorkflowLoadError(`${p} type=loop for-each 模式必须带 'as'`, file);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// while 模式:as 可选(不设则不注入迭代变量)
|
|
238
|
+
if (s.as !== undefined && (typeof s.as !== 'string' || s.as.length === 0)) {
|
|
239
|
+
throw new WorkflowLoadError(`${p} type=loop while 模式的 'as' 必须是非空字符串`, file);
|
|
240
|
+
}
|
|
203
241
|
}
|
|
204
242
|
if (!Array.isArray(s.do)) {
|
|
205
243
|
throw new WorkflowLoadError(`${p} type=loop 必须带 'do' 数组`, file);
|
|
206
244
|
}
|
|
207
245
|
validateSteps(s.do, file, `${p}.do`);
|
|
208
246
|
}
|
|
247
|
+
if (s.type === 'break') {
|
|
248
|
+
// break 是循环退出信号,通常配合 when 条件使用
|
|
249
|
+
// 无额外必填字段;when 缺失时等价于无条件跳出(极少用)
|
|
250
|
+
}
|
|
209
251
|
if (s.type === 'assert' && s.condition === undefined) {
|
|
210
252
|
throw new WorkflowLoadError(`${p} type=assert 必须带 'condition'`, file);
|
|
211
253
|
}
|
|
212
254
|
if (s.type === 'verify') {
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
// 兼容两种格式:YAML 数组 或逗号分隔字符串
|
|
256
|
+
const rawChecks = Array.isArray(s.checks)
|
|
257
|
+
? s.checks
|
|
258
|
+
: typeof s.checks === 'string'
|
|
259
|
+
? s.checks.split(',').map((x) => x.trim()).filter(Boolean)
|
|
260
|
+
: [];
|
|
261
|
+
if (rawChecks.length === 0) {
|
|
262
|
+
throw new WorkflowLoadError(`${p} type=verify 必须带 checks(数组或逗号分隔字符串)`, file);
|
|
215
263
|
}
|
|
216
|
-
for (const [ci, c] of
|
|
264
|
+
for (const [ci, c] of rawChecks.entries()) {
|
|
217
265
|
if (typeof c !== 'string' || !c.includes(':')) {
|
|
218
266
|
throw new WorkflowLoadError(`${p}.checks[${ci}] 格式错误,应为 "type:value"(如 "file_exists:./x")`, file);
|
|
219
267
|
}
|
|
@@ -313,7 +361,7 @@ function validateSteps(steps, file, pathPrefix) {
|
|
|
313
361
|
throw new WorkflowLoadError(`${p}.context_files 不能用在 type=${s.type} 控制流节点上`, file);
|
|
314
362
|
}
|
|
315
363
|
// P3:context_files 同样是 skill 节点的静默 no-op(execSkillStep 无视它),
|
|
316
|
-
// 仅 llm 节点经
|
|
364
|
+
// 仅 llm 节点经 runQuery 生效——loader 直接拒绝,避免悄悄丢弃。
|
|
317
365
|
if (s.skill !== undefined) {
|
|
318
366
|
throw new WorkflowLoadError(`${p}.context_files 暂不支持 skill 节点(仅 llm 节点生效)`, file);
|
|
319
367
|
}
|
|
@@ -332,5 +380,19 @@ function validateSteps(steps, file, pathPrefix) {
|
|
|
332
380
|
throw new WorkflowLoadError(`${p}.allowed_tools 暂不支持 skill 节点(仅 llm 节点生效)`, file);
|
|
333
381
|
}
|
|
334
382
|
}
|
|
383
|
+
// ---------------- Phase 1: 断点续执行 / 自愈字段校验 ----------------
|
|
384
|
+
// step.artifacts: string[](可选)
|
|
385
|
+
if (s.artifacts !== undefined) {
|
|
386
|
+
if (!Array.isArray(s.artifacts) || !s.artifacts.every((x) => typeof x === 'string' && x.length > 0)) {
|
|
387
|
+
throw new WorkflowLoadError(`${p}.artifacts 必须是非空字符串数组`, file);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// step.max_retries: number(可选,>= 0 的整数)
|
|
391
|
+
if (s.max_retries !== undefined) {
|
|
392
|
+
const mr = s.max_retries;
|
|
393
|
+
if (typeof mr !== 'number' || !Number.isInteger(mr) || mr < 0) {
|
|
394
|
+
throw new WorkflowLoadError(`${p}.max_retries 必须是 >= 0 的整数`, file);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
335
397
|
}
|
|
336
398
|
}
|
|
@@ -6,30 +6,26 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 传统 DAG 的脆点:某节点抛错 → 整条 halt(或 onError:'continue' 盲跳,下游
|
|
8
8
|
* capture 拿 undefined 静默级联错)。当节点声明 onError:'recover' 时,runner
|
|
9
|
-
* 不立刻 halt,而是调本模块起一个**有界恢复 agent
|
|
10
|
-
*
|
|
9
|
+
* 不立刻 halt,而是调本模块起一个**有界恢复 agent**(委托 runQuery/TAOR),
|
|
10
|
+
* 喂给它「工作流总目标 + 失败节点的意图 + 错误信息 + 当前已知变量 + 下游 capture 契约」,
|
|
11
11
|
* 让它**随机应变去达成该节点本来要的结果**(换路径 / 换命令 / 先读后写…)。
|
|
12
12
|
*
|
|
13
|
+
* v0.9 重构:不再使用独立 miniReAct 子循环,而是委托主 TAOR 引擎(runQuery)
|
|
14
|
+
* 在共享 ctx.history 上执行。恢复过程也写入共享上下文——后续对话能看到"某步失败后
|
|
15
|
+
* 如何被自动修复",增强可解释性。
|
|
16
|
+
*
|
|
13
17
|
* —— 共享大脑 ——
|
|
14
|
-
* 恢复 agent
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 绑给下游。返回 AgentOutcome:
|
|
18
|
+
* 恢复 agent 与正常 agent/llm/skill 步本质同构:都通过 runQuery(TAOR) 执行。
|
|
19
|
+
* 本模块只构造「救火」角色提示词,跑完后交给 interpretMiniReActResult 判定
|
|
20
|
+
* 「真救回 / 放弃」。返回 AgentOutcome:
|
|
18
21
|
* - { ok:true, value } —— runner 用同一个 step.capture 绑定后继续 happy path
|
|
19
22
|
* - { ok:false, reason } —— runner 带原因 honest halt
|
|
20
|
-
*
|
|
21
|
-
* 设计取舍:复用 miniReAct 而非 runQuery —— 恢复要的正是「有界、白名单、不污染主
|
|
22
|
-
* history、直接返回 StepResult」;默认全量 ALL_TOOLS(与 skill/agent 节点同等授权、
|
|
23
|
-
* 同一信任边界),step.allowed_tools 可收窄。本函数是 AsyncGenerator,yield*
|
|
24
|
-
* runMiniReAct 把恢复 agent 的内层思考透传给 UI;warning/plugin_progress/halt 仍由
|
|
25
|
-
* runner 在调用前后发(事件卫生集中在 runner)。
|
|
26
23
|
* ============================================================
|
|
27
24
|
*/
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
25
|
+
import { runQuery } from '../../../src/loop.js';
|
|
26
|
+
import { describeCaptureContract, interpretMiniReActResult, truncate, } from './agentCommon.js';
|
|
30
27
|
import { interpolate, interpolateDeep } from './expressions.js';
|
|
31
|
-
|
|
32
|
-
// RECOVERY_SYSTEM 现在是 async(需要扫描 skill/plugin),在 runRecovery 内部构建
|
|
28
|
+
// RECOVER_OPENING 作为用户消息的一部分(系统提示词已在 history[0] 中)
|
|
33
29
|
const RECOVER_OPENING = `你是一名资深的自动化工程师 / SRE,被一个确定性工作流临时调来「救火」。
|
|
34
30
|
工作流里一个原本写死的步骤刚刚执行失败了,现在交给你随机应变——先看错误信息诊断根因,再想办法真正达成这个步骤**本来要的结果**(关注它要的结果,别拘泥它原先怎么写)。`;
|
|
35
31
|
/** 描述失败节点「本来要干什么」,喂给恢复 agent 作为意图说明。 */
|
|
@@ -57,7 +53,6 @@ function describeStepIntent(step, vars) {
|
|
|
57
53
|
* 尝试自适应恢复一个失败的动作 step。
|
|
58
54
|
* - 救回 → { ok:true, value }(value.raw 已 normalize,可被原 step.capture 绑定)
|
|
59
55
|
* - 放弃 / 不可信 / 子循环抛错(含用户中断)→ { ok:false, reason }
|
|
60
|
-
* (中断的最终语义由调用方在拿到结果后另行检测 ctx.signal 决定)
|
|
61
56
|
*/
|
|
62
57
|
export async function* runRecovery(step, vars, ctx, errorMsg) {
|
|
63
58
|
const intent = describeStepIntent(step, vars);
|
|
@@ -67,33 +62,46 @@ export async function* runRecovery(step, vars, ctx, errorMsg) {
|
|
|
67
62
|
const hintLine = step.recover_hint
|
|
68
63
|
? `\n\n作者给恢复的提示:${interpolate(step.recover_hint, vars)}`
|
|
69
64
|
: '';
|
|
70
|
-
|
|
65
|
+
// 构造恢复 prompt(作为 user message push 进共享 history)
|
|
66
|
+
const recoverPrompt = `${RECOVER_OPENING}\n\n` +
|
|
67
|
+
`${goalLine}` +
|
|
71
68
|
`失败的步骤(id=${step.id})本来要做的事:\n${intent}\n\n` +
|
|
72
69
|
`它失败时的错误:\n${truncate(errorMsg, 800)}\n\n` +
|
|
73
70
|
`当前已知的变量(JSON):\n${knownVars}` +
|
|
74
71
|
`${captureLine}${hintLine}\n\n` +
|
|
75
72
|
`请随机应变,把这一步要的结果做出来;做不成就按约定输出放弃标记。`;
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
const
|
|
83
|
-
const messages = [
|
|
84
|
-
{ role: 'system', content: systemContent },
|
|
85
|
-
{ role: 'user', content: userMsg },
|
|
86
|
-
];
|
|
73
|
+
// output_schema 约束嵌入 prompt
|
|
74
|
+
const schemaHint = step.output_schema
|
|
75
|
+
? `\n\n【结构化输出约束】请严格按照以下 JSON Schema 输出结果:\n${JSON.stringify(step.output_schema, null, 2)}`
|
|
76
|
+
: '';
|
|
77
|
+
const fullPrompt = recoverPrompt + schemaHint;
|
|
78
|
+
// 记录 history 长度用于提取本步输出
|
|
79
|
+
const historyLenBefore = ctx.history.length;
|
|
87
80
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
tools,
|
|
91
|
-
output_schema: step.output_schema,
|
|
81
|
+
// 委托 TAOR 引擎执行——事件透传给 UI(显示恢复进度)
|
|
82
|
+
yield* runQuery(fullPrompt, {
|
|
92
83
|
provider: ctx.provider,
|
|
84
|
+
history: ctx.history,
|
|
93
85
|
signal: ctx.signal,
|
|
94
86
|
maxTurns: step.maxTurns ?? 12,
|
|
87
|
+
chat: ctx.chat,
|
|
88
|
+
executeTool: ctx.executeTool,
|
|
89
|
+
sessionState: ctx.sessionState,
|
|
95
90
|
});
|
|
96
|
-
|
|
91
|
+
// 从 history 提取本步最后一条 assistant 消息
|
|
92
|
+
let rawText = '';
|
|
93
|
+
for (let i = ctx.history.length - 1; i >= historyLenBefore; i--) {
|
|
94
|
+
const msg = ctx.history[i];
|
|
95
|
+
if (msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.trim()) {
|
|
96
|
+
rawText = msg.content;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const stepResult = {
|
|
101
|
+
raw: { text: rawText, result: rawText },
|
|
102
|
+
preview: rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText,
|
|
103
|
+
};
|
|
104
|
+
const outcome = interpretMiniReActResult(stepResult, !!step.output_schema);
|
|
97
105
|
if (!outcome.ok)
|
|
98
106
|
return outcome;
|
|
99
107
|
// 救回:preview 加 recovered: 前缀(runner 的 step_end output 用),raw 原样。
|
|
@@ -103,7 +111,7 @@ export async function* runRecovery(step, vars, ctx, errorMsg) {
|
|
|
103
111
|
};
|
|
104
112
|
}
|
|
105
113
|
catch (e) {
|
|
106
|
-
//
|
|
114
|
+
// runQuery 抛错(用尽轮数 / 中断)→ 恢复失败。
|
|
107
115
|
return { ok: false, reason: e.message ?? String(e) };
|
|
108
116
|
}
|
|
109
117
|
}
|