minimal-agent 0.8.1 → 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 +5 -3
- package/workflows/book-review-short.yaml +5 -3
- package/workflows/e2e-write-greet.yaml +3 -2
- package/workflows/plan.yaml +219 -167
- 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
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* plugins/ralph-wiggum/src/stopHookRunner.ts
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* ralph-wiggum 私用的 stop-hook 执行器。读 hooks/hooks.json 里 Stop 事件
|
|
6
|
-
* 的命令清单,stdin 传 transcript 文本,stdout 收 JSON 决策。
|
|
7
|
-
*
|
|
8
|
-
* 在 minimal-agent 里 stop-hook 是"咨询式"信号:
|
|
9
|
-
* - 返回 block → driver 把 reason 注入下一轮 prompt
|
|
10
|
-
* - pass / 错误 / Windows 缺 bash → 不影响循环继续
|
|
11
|
-
*
|
|
12
|
-
* 与框架级 hookEngine 的关系:hookEngine 服务于 UserPromptSubmit /
|
|
13
|
-
* PreToolUse / PostToolUse 等框架事件;ralph 的 Stop 是插件领域内
|
|
14
|
-
* 特有的循环-反馈机制,故而独立执行,不污染框架 hookTable。
|
|
15
|
-
* ============================================================
|
|
16
|
-
*/
|
|
17
|
-
import { readFile } from 'node:fs/promises';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
|
-
async function loadStopHookConfig(pluginRoot) {
|
|
21
|
-
const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
|
|
22
|
-
try {
|
|
23
|
-
const raw = await readFile(hooksJsonPath, 'utf8');
|
|
24
|
-
const parsed = JSON.parse(raw);
|
|
25
|
-
const stopEntries = parsed?.hooks?.Stop;
|
|
26
|
-
if (!Array.isArray(stopEntries))
|
|
27
|
-
return [];
|
|
28
|
-
const commands = [];
|
|
29
|
-
for (const entry of stopEntries) {
|
|
30
|
-
const hooks = entry.hooks;
|
|
31
|
-
if (Array.isArray(hooks)) {
|
|
32
|
-
for (const h of hooks) {
|
|
33
|
-
if (h.type === 'command' && h.command) {
|
|
34
|
-
commands.push(h);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return commands;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
export async function executeStopHook(pluginRoot, transcriptText) {
|
|
46
|
-
// Windows 上 bash 通常缺失,hook 链路降级为 pass(咨询式信号不影响循环)
|
|
47
|
-
if (process.platform === 'win32') {
|
|
48
|
-
return { decision: 'pass' };
|
|
49
|
-
}
|
|
50
|
-
const hookConfigs = await loadStopHookConfig(pluginRoot);
|
|
51
|
-
for (const hookConfig of hookConfigs) {
|
|
52
|
-
const result = await runSingleStopHook(hookConfig, pluginRoot, transcriptText);
|
|
53
|
-
if (result.decision === 'block') {
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { decision: 'pass' };
|
|
58
|
-
}
|
|
59
|
-
function runSingleStopHook(hookConfig, pluginRoot, transcriptText) {
|
|
60
|
-
const resolvedCommand = hookConfig.command.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot);
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
const child = spawn('bash', [resolvedCommand], {
|
|
63
|
-
env: {
|
|
64
|
-
...process.env,
|
|
65
|
-
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
let stdout = '';
|
|
69
|
-
child.stdout.on('data', (data) => {
|
|
70
|
-
stdout += data.toString();
|
|
71
|
-
});
|
|
72
|
-
child.on('error', () => {
|
|
73
|
-
resolve({ decision: 'pass' });
|
|
74
|
-
});
|
|
75
|
-
child.on('close', (code) => {
|
|
76
|
-
if (code !== 0) {
|
|
77
|
-
resolve({ decision: 'pass' });
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
const trimmed = stdout.trim();
|
|
81
|
-
if (!trimmed) {
|
|
82
|
-
resolve({ decision: 'pass' });
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
try {
|
|
86
|
-
const parsed = JSON.parse(trimmed);
|
|
87
|
-
if (parsed.decision === 'block') {
|
|
88
|
-
resolve({
|
|
89
|
-
decision: 'block',
|
|
90
|
-
reason: typeof parsed.reason === 'string' ? parsed.reason : undefined,
|
|
91
|
-
systemMessage: typeof parsed.systemMessage === 'string' ? parsed.systemMessage : undefined,
|
|
92
|
-
});
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
resolve({ decision: 'pass' });
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
resolve({ decision: 'pass' });
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
child.stdin.write(transcriptText);
|
|
102
|
-
child.stdin.end();
|
|
103
|
-
});
|
|
104
|
-
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* src/plugins/verificationGate.ts —— ralph-loop 的"完成验证门"
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* Agent 输出 <promise>DONE</promise> 哨兵后,pluginRunner 跑这里的
|
|
6
|
-
* checks。全部通过才真正退出循环;任一未通过则回 BUILD 继续干。
|
|
7
|
-
*
|
|
8
|
-
* 支持四种 check:
|
|
9
|
-
* - shell:<cmd> 跨平台 spawn(Windows 走 cmd /c, 其余 bash -c)
|
|
10
|
-
* - file_exists:<path> fs.existsSync
|
|
11
|
-
* - file_contains:<file>:<re> 正则匹配文件内容
|
|
12
|
-
* - test_count:<min> 跑 `bun test`,从 stdout 解析 "N pass"
|
|
13
|
-
*
|
|
14
|
-
* 工作目录隔离:所有相对路径以 getWorkingDir() 为锚,子进程 cwd 也用它,
|
|
15
|
-
* 确保并行 session 不会互相串台。
|
|
16
|
-
* ============================================================
|
|
17
|
-
*/
|
|
18
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
|
-
function parseVerifyArg(arg) {
|
|
21
|
-
const colonIdx = arg.indexOf(':');
|
|
22
|
-
if (colonIdx < 0)
|
|
23
|
-
return null;
|
|
24
|
-
const type = arg.slice(0, colonIdx).trim().toLowerCase();
|
|
25
|
-
const value = arg.slice(colonIdx + 1).trim();
|
|
26
|
-
switch (type) {
|
|
27
|
-
case 'shell':
|
|
28
|
-
return { type: 'shell', command: value, timeout: 30_000 };
|
|
29
|
-
case 'file_exists':
|
|
30
|
-
return { type: 'file_exists', file: value };
|
|
31
|
-
case 'file_contains': {
|
|
32
|
-
const sep = value.indexOf(':');
|
|
33
|
-
if (sep < 0)
|
|
34
|
-
return null;
|
|
35
|
-
return {
|
|
36
|
-
type: 'file_contains',
|
|
37
|
-
file: value.slice(0, sep),
|
|
38
|
-
pattern: value.slice(sep + 1),
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
case 'test_count': {
|
|
42
|
-
const count = parseInt(value, 10);
|
|
43
|
-
if (isNaN(count) || count < 0)
|
|
44
|
-
return null;
|
|
45
|
-
return { type: 'test_count', minCount: count };
|
|
46
|
-
}
|
|
47
|
-
default:
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
export function parseVerifyArgs(args) {
|
|
52
|
-
const checks = [];
|
|
53
|
-
const regex = /--verify\s+("[^"]*"|\S+)/gi;
|
|
54
|
-
let match;
|
|
55
|
-
while ((match = regex.exec(args)) !== null) {
|
|
56
|
-
const raw = match[1].replace(/^"|"$/g, '');
|
|
57
|
-
const check = parseVerifyArg(raw);
|
|
58
|
-
if (check)
|
|
59
|
-
checks.push(check);
|
|
60
|
-
}
|
|
61
|
-
return checks;
|
|
62
|
-
}
|
|
63
|
-
function runShell(command, timeout) {
|
|
64
|
-
return new Promise((resolve) => {
|
|
65
|
-
const isWin = process.platform === 'win32';
|
|
66
|
-
// 不显式传 cwd —— 继承 process.cwd(),main.tsx 已 chdir 到 workingDir,
|
|
67
|
-
// 而测试里直接用 process.cwd(),避免依赖 mock 的 getWorkingDir 造成串台
|
|
68
|
-
const child = isWin
|
|
69
|
-
? spawn('cmd', ['/c', command], { timeout, env: process.env })
|
|
70
|
-
: spawn('bash', ['-c', command], { timeout, env: process.env });
|
|
71
|
-
let stdout = '';
|
|
72
|
-
let stderr = '';
|
|
73
|
-
child.stdout.on('data', (d) => {
|
|
74
|
-
stdout += d.toString();
|
|
75
|
-
});
|
|
76
|
-
child.stderr.on('data', (d) => {
|
|
77
|
-
stderr += d.toString();
|
|
78
|
-
});
|
|
79
|
-
child.on('error', () => {
|
|
80
|
-
resolve({ exitCode: null, stdout, stderr, errored: true });
|
|
81
|
-
});
|
|
82
|
-
child.on('close', (code) => {
|
|
83
|
-
resolve({ exitCode: code, stdout, stderr, errored: false });
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
export async function verifyShell(command, timeout) {
|
|
88
|
-
const r = await runShell(command, timeout);
|
|
89
|
-
if (r.errored) {
|
|
90
|
-
return {
|
|
91
|
-
check: { type: 'shell', command },
|
|
92
|
-
passed: false,
|
|
93
|
-
output: `执行失败`,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
return {
|
|
97
|
-
check: { type: 'shell', command },
|
|
98
|
-
passed: r.exitCode === 0,
|
|
99
|
-
output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
export function verifyFileExists(file) {
|
|
103
|
-
const exists = existsSync(file);
|
|
104
|
-
return {
|
|
105
|
-
check: { type: 'file_exists', file },
|
|
106
|
-
passed: exists,
|
|
107
|
-
output: exists ? '文件存在' : `文件不存在: ${file}`,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
export function verifyFileContains(file, pattern) {
|
|
111
|
-
try {
|
|
112
|
-
const content = readFileSync(file, 'utf8');
|
|
113
|
-
const regex = new RegExp(pattern);
|
|
114
|
-
const matched = regex.test(content);
|
|
115
|
-
return {
|
|
116
|
-
check: { type: 'file_contains', file, pattern },
|
|
117
|
-
passed: matched,
|
|
118
|
-
output: matched ? `文件包含 "${pattern}"` : `文件不包含 "${pattern}"`,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
return {
|
|
123
|
-
check: { type: 'file_contains', file, pattern },
|
|
124
|
-
passed: false,
|
|
125
|
-
output: `无法读取文件: ${file}`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
async function verifyTestCount(minCount) {
|
|
130
|
-
const r = await runShell(`bun test`, 60_000);
|
|
131
|
-
// bun test 把进度打在 stderr,最终统计也常在 stderr,所以合并解析
|
|
132
|
-
const combined = `${r.stdout}\n${r.stderr}`;
|
|
133
|
-
if (r.errored) {
|
|
134
|
-
return {
|
|
135
|
-
check: { type: 'test_count', minCount },
|
|
136
|
-
passed: false,
|
|
137
|
-
output: '无法执行 bun test',
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
const matches = [...combined.matchAll(/(\d+)\s+pass/gi)];
|
|
141
|
-
const count = matches.length > 0 ? parseInt(matches[matches.length - 1][1], 10) : 0;
|
|
142
|
-
const passed = count >= minCount;
|
|
143
|
-
return {
|
|
144
|
-
check: { type: 'test_count', minCount },
|
|
145
|
-
passed,
|
|
146
|
-
output: `${count} pass (需要 >=${minCount})`,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
export async function runVerification(checks) {
|
|
150
|
-
if (checks.length === 0) {
|
|
151
|
-
return { passed: true, details: [], summary: '无验证项' };
|
|
152
|
-
}
|
|
153
|
-
const results = [];
|
|
154
|
-
for (const check of checks) {
|
|
155
|
-
let result;
|
|
156
|
-
switch (check.type) {
|
|
157
|
-
case 'shell':
|
|
158
|
-
result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
|
|
159
|
-
break;
|
|
160
|
-
case 'file_exists':
|
|
161
|
-
result = verifyFileExists(check.file ?? '');
|
|
162
|
-
break;
|
|
163
|
-
case 'file_contains':
|
|
164
|
-
result = verifyFileContains(check.file ?? '', check.pattern ?? '');
|
|
165
|
-
break;
|
|
166
|
-
case 'test_count':
|
|
167
|
-
result = await verifyTestCount(check.minCount ?? 0);
|
|
168
|
-
break;
|
|
169
|
-
default:
|
|
170
|
-
result = {
|
|
171
|
-
check,
|
|
172
|
-
passed: false,
|
|
173
|
-
output: `未知验证类型: ${check.type}`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
results.push(result);
|
|
177
|
-
}
|
|
178
|
-
const allPassed = results.every((r) => r.passed);
|
|
179
|
-
const failedNames = results.filter((r) => !r.passed).map((r) => formatCheckName(r.check));
|
|
180
|
-
let summary;
|
|
181
|
-
if (allPassed) {
|
|
182
|
-
summary = `✅ 全部通过 (${results.length}/${results.length})`;
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
summary = `❌ 验证未通过: ${failedNames.join(', ')}`;
|
|
186
|
-
}
|
|
187
|
-
return { passed: allPassed, details: results, summary };
|
|
188
|
-
}
|
|
189
|
-
function formatCheckName(check) {
|
|
190
|
-
switch (check.type) {
|
|
191
|
-
case 'shell':
|
|
192
|
-
return `shell(${(check.command ?? '').slice(0, 40)})`;
|
|
193
|
-
case 'file_exists':
|
|
194
|
-
return `exists(${check.file})`;
|
|
195
|
-
case 'file_contains':
|
|
196
|
-
return `contains(${check.file}:${check.pattern})`;
|
|
197
|
-
case 'test_count':
|
|
198
|
-
return `tests(>=${check.minCount})`;
|
|
199
|
-
default:
|
|
200
|
-
return check.type;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* plugins/workflow-runner/src/miniReAct.ts —— 简化版 ReAct 子循环
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 本循环只做最纯净的 ReAct:调 LLM → 执行 tool_call → 喂回 → 再调 → 直到模型
|
|
6
|
-
* 不再调工具或达到 maxTurns。**有三个调用方**(都要「有界、白名单、不污染主
|
|
7
|
-
* history、直接拿 StepResult」):
|
|
8
|
-
* - execLlmStep 的 context_files 分支(llm.ts,maxTurns 默认 10)
|
|
9
|
-
* - agent: 节点(stepExecutors/agent.ts,默认 12)
|
|
10
|
-
* - onError:'recover' 恢复 agent(recovery.ts,默认 12)
|
|
11
|
-
*
|
|
12
|
-
* **流式**:本循环边跑边 yield UI 事件——text(答案)/ reasoning(思维链)/
|
|
13
|
-
* tool_start/tool_end(工具)/ **stage_change(thinking→streaming→tool_executing,
|
|
14
|
-
* 对齐 src/loop.ts::runQuery)**。stage_change 是 TUI LiveArea 显示的开关(idle 时
|
|
15
|
-
* LiveArea 整块隐藏),没有它则上述内层事件在 TUI 上发了也看不见。output_schema
|
|
16
|
-
* 模式下**仍切 stage、但不回显 text**(避免把 JSON 原文当答案刷屏,对齐 llm.ts)。
|
|
17
|
-
*
|
|
18
|
-
* 为什么不复用 src/loop.ts::runQuery?
|
|
19
|
-
* - runQuery 依赖太重:history 持久化、自动压缩、反应式压缩自救、
|
|
20
|
-
* microCompact、reasoning 累积、sessionState 注入、SIGINT 标记消息等
|
|
21
|
-
* - workflow 子步骤是"短小内嵌",不需要这些工程化保障
|
|
22
|
-
* - 独立实现可以精准控制:只用白名单工具、强 schema、重复调用 halt
|
|
23
|
-
*
|
|
24
|
-
* 关键决策:
|
|
25
|
-
* 1. **每轮都传 responseFormat**(如果有 output_schema)。OpenAI 兼容
|
|
26
|
-
* provider 对 tool_calls + json_schema 混用通常允许:tool_calls 走 function
|
|
27
|
-
* 协议,最终 assistant 的 text 是 JSON。这样比"双轮探测"简单可靠。
|
|
28
|
-
* 2. **重复工具调用检测**:同 name 同 args 出现 3 次直接 halt,避免模型卡死。
|
|
29
|
-
* 3. **executeTool 自带 Zod 校验和错误包装**,本循环只需透传 tool_call_id 配对。
|
|
30
|
-
* 4. **maxTurns 默认 10**(vs runQuery 的 50),workflow 子步骤天生该收敛快。
|
|
31
|
-
* ============================================================
|
|
32
|
-
*/
|
|
33
|
-
import { chat, executeTool, } from '../../../src/plugin-sdk.js';
|
|
34
|
-
import { extractJsonPayload } from './jsonPayload.js';
|
|
35
|
-
/**
|
|
36
|
-
* 执行一次 ReAct 子循环。**边跑边 yield UI 事件(stage_change / text / reasoning /
|
|
37
|
-
* tool_start / tool_end),同时 return 最终 StepResult**——让 recover / agent 节点 /
|
|
38
|
-
* llm+context_files 三处子 agent 的内层思考在 TUI / webchat 实时可见(之前是纯 async
|
|
39
|
-
* 不 yield,"哑"的;且漏发 stage_change 时 TUI LiveArea 整块隐藏,等于没显示)。
|
|
40
|
-
* 调用方用 `const result = yield* runMiniReAct(...)` 既透传事件、又拿到 StepResult。
|
|
41
|
-
*
|
|
42
|
-
* 抛错场景:
|
|
43
|
-
* - 用户中断(signal.aborted)
|
|
44
|
-
* - 重复工具调用 >= 3 次
|
|
45
|
-
* - 用尽 maxTurns 仍未收敛
|
|
46
|
-
* - output_schema 解析失败 / 非对象
|
|
47
|
-
*/
|
|
48
|
-
export async function* runMiniReAct(args) {
|
|
49
|
-
const { messages, tools, output_schema, provider, signal } = args;
|
|
50
|
-
const maxTurns = args.maxTurns ?? 10;
|
|
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
|
-
}
|
|
65
|
-
let finalText = '';
|
|
66
|
-
/** 上一轮是否仍有 tool_calls(用于检测 "用尽轮数还在调工具" 这一退出态) */
|
|
67
|
-
let lastTurnHadToolCalls = false;
|
|
68
|
-
/** 重复工具调用计数:key = `${name}:${arguments}` */
|
|
69
|
-
const toolCallSignature = new Map();
|
|
70
|
-
const responseFormat = output_schema
|
|
71
|
-
? {
|
|
72
|
-
type: 'json_schema',
|
|
73
|
-
json_schema: {
|
|
74
|
-
name: 'minireact_output',
|
|
75
|
-
schema: output_schema,
|
|
76
|
-
strict: true,
|
|
77
|
-
},
|
|
78
|
-
}
|
|
79
|
-
: undefined;
|
|
80
|
-
for (let turn = 0; turn < maxTurns; turn++) {
|
|
81
|
-
if (signal?.aborted)
|
|
82
|
-
throw new Error('用户中断');
|
|
83
|
-
// ---- 1. 调 LLM 并累积流式响应 ----
|
|
84
|
-
let textAccum = '';
|
|
85
|
-
const toolCallsAccum = [];
|
|
86
|
-
// 进 LLM 流前本质是「等首 token」= thinking;首个 text_delta 时切 streaming(一次性)。
|
|
87
|
-
// 对齐 src/loop.ts::runQuery —— 没有这两个 stage_change,TUI LiveArea 会一直 idle 隐藏,
|
|
88
|
-
// 下面 yield 的 text/reasoning/tool 事件在 TUI 上等于白发。
|
|
89
|
-
let stageStreamingYielded = false;
|
|
90
|
-
yield { type: 'stage_change', stage: 'thinking' };
|
|
91
|
-
for await (const ev of chat({
|
|
92
|
-
provider,
|
|
93
|
-
messages: conv,
|
|
94
|
-
tools,
|
|
95
|
-
responseFormat,
|
|
96
|
-
signal,
|
|
97
|
-
})) {
|
|
98
|
-
if (ev.type === 'text_delta') {
|
|
99
|
-
textAccum += ev.delta;
|
|
100
|
-
if (!stageStreamingYielded) {
|
|
101
|
-
stageStreamingYielded = true;
|
|
102
|
-
yield { type: 'stage_change', stage: 'streaming' };
|
|
103
|
-
}
|
|
104
|
-
// output_schema 模式:切了 streaming 阶段但**不回显 JSON 原文**(不把结构化产出
|
|
105
|
-
// 当答案刷屏,对齐 llm.ts:121 的克制);非 schema 模式才把答案流式 yield 给 UI。
|
|
106
|
-
if (!output_schema)
|
|
107
|
-
yield { type: 'text', delta: ev.delta };
|
|
108
|
-
}
|
|
109
|
-
else if (ev.type === 'tool_call_delta') {
|
|
110
|
-
mergeToolCallDelta(toolCallsAccum, ev);
|
|
111
|
-
}
|
|
112
|
-
else if (ev.type === 'reasoning_delta') {
|
|
113
|
-
// 思维链字符串增量透传给 UI(reasoning_details 是结构化数组、无 delta,跳过)
|
|
114
|
-
if (ev.delta)
|
|
115
|
-
yield { type: 'reasoning', delta: ev.delta };
|
|
116
|
-
}
|
|
117
|
-
else if (ev.type === 'done' && ev.stopReason === 'aborted') {
|
|
118
|
-
throw new Error('用户中断');
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// ---- 2. 把 assistant 消息推入 conv ----
|
|
122
|
-
conv.push({
|
|
123
|
-
role: 'assistant',
|
|
124
|
-
content: textAccum.length > 0 ? textAccum : null,
|
|
125
|
-
...(toolCallsAccum.length > 0 ? { tool_calls: toolCallsAccum } : {}),
|
|
126
|
-
});
|
|
127
|
-
// ---- 3. 无 tool_calls → 结束循环 ----
|
|
128
|
-
if (toolCallsAccum.length === 0) {
|
|
129
|
-
finalText = textAccum.trim();
|
|
130
|
-
lastTurnHadToolCalls = false;
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
lastTurnHadToolCalls = true;
|
|
134
|
-
// ---- 4. 执行所有 tool_calls,结果回填为 tool 消息 ----
|
|
135
|
-
// 进工具执行 = tool_executing 阶段(驱动 TUI LiveArea 显示运行中工具,对齐 loop.ts:340)。
|
|
136
|
-
yield { type: 'stage_change', stage: 'tool_executing' };
|
|
137
|
-
for (const tc of toolCallsAccum) {
|
|
138
|
-
// 重复检测
|
|
139
|
-
const sig = `${tc.function.name}:${tc.function.arguments}`;
|
|
140
|
-
const count = (toolCallSignature.get(sig) ?? 0) + 1;
|
|
141
|
-
toolCallSignature.set(sig, count);
|
|
142
|
-
if (count >= 3) {
|
|
143
|
-
throw new Error(`miniReAct: 检测到重复工具调用(${tc.function.name} 同参数已 ${count} 次),可能陷入循环,halt。`);
|
|
144
|
-
}
|
|
145
|
-
if (signal?.aborted)
|
|
146
|
-
throw new Error('用户中断');
|
|
147
|
-
// 执行前后 yield tool_start/tool_end,让 UI 看到子 agent 在用工具(事件形状对齐
|
|
148
|
-
// src/loop.ts;miniReAct 串行执行,顺序 yield 即可,不需要 loop 的并行事件队列)。
|
|
149
|
-
yield {
|
|
150
|
-
type: 'tool_start',
|
|
151
|
-
toolName: tc.function.name,
|
|
152
|
-
toolCallId: tc.id,
|
|
153
|
-
argsPreview: previewArgs(tc.function.arguments),
|
|
154
|
-
};
|
|
155
|
-
const result = await executeTool(tc.function.name, tc.function.arguments, signal);
|
|
156
|
-
const toolContent = result.ok ? result.content : `工具错误: ${result.error}`;
|
|
157
|
-
yield {
|
|
158
|
-
type: 'tool_end',
|
|
159
|
-
toolName: tc.function.name,
|
|
160
|
-
toolCallId: tc.id,
|
|
161
|
-
ok: result.ok,
|
|
162
|
-
content: toolContent,
|
|
163
|
-
};
|
|
164
|
-
conv.push({
|
|
165
|
-
role: 'tool',
|
|
166
|
-
tool_call_id: tc.id,
|
|
167
|
-
content: toolContent,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
// 继续下一轮
|
|
171
|
-
}
|
|
172
|
-
// 用尽 maxTurns 且最后一轮仍有 tool_calls
|
|
173
|
-
if (lastTurnHadToolCalls) {
|
|
174
|
-
throw new Error(`miniReAct: 用尽 ${maxTurns} 轮未得到最终回答(仍在调工具)`);
|
|
175
|
-
}
|
|
176
|
-
// ---- 5. 输出处理 ----
|
|
177
|
-
if (output_schema) {
|
|
178
|
-
// 同 llm.ts:先剥 <think> 思维链/围栏/前后散文再 parse(见 jsonPayload.ts 头注)。
|
|
179
|
-
const payload = extractJsonPayload(finalText);
|
|
180
|
-
let parsed;
|
|
181
|
-
try {
|
|
182
|
-
parsed = JSON.parse(payload);
|
|
183
|
-
}
|
|
184
|
-
catch (e) {
|
|
185
|
-
throw new Error(`miniReAct: output_schema 要求 JSON 但解析失败: ${e.message}\n收到: ${finalText.slice(0, 200)}`);
|
|
186
|
-
}
|
|
187
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
188
|
-
throw new Error(`miniReAct: output_schema 要求对象,得到 ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
raw: parsed,
|
|
192
|
-
preview: `[json+tools] ${finalText.slice(0, 180)}${finalText.length > 180 ? '...' : ''}`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
return {
|
|
196
|
-
raw: { text: finalText, result: finalText },
|
|
197
|
-
preview: finalText.length > 200 ? `${finalText.slice(0, 200)}...` : finalText,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
// ---------------- 辅助 ----------------
|
|
201
|
-
/** 把一条 tool_call_delta 合并到累积数组上(与 src/loop.ts mergeToolCallDelta 同语义) */
|
|
202
|
-
function mergeToolCallDelta(acc, ev) {
|
|
203
|
-
let slot = acc[ev.index];
|
|
204
|
-
if (!slot) {
|
|
205
|
-
slot = {
|
|
206
|
-
id: '',
|
|
207
|
-
type: 'function',
|
|
208
|
-
function: { name: '', arguments: '' },
|
|
209
|
-
};
|
|
210
|
-
acc[ev.index] = slot;
|
|
211
|
-
}
|
|
212
|
-
if (ev.id)
|
|
213
|
-
slot.id = ev.id;
|
|
214
|
-
if (ev.name)
|
|
215
|
-
slot.function.name += ev.name;
|
|
216
|
-
if (ev.argumentsDelta)
|
|
217
|
-
slot.function.arguments += ev.argumentsDelta;
|
|
218
|
-
}
|
|
219
|
-
/** 给 tool_start 事件用的参数预览(压成一行、截断到 60 字符),与 loop.ts previewArgs 同义。 */
|
|
220
|
-
function previewArgs(rawJson) {
|
|
221
|
-
const oneLine = rawJson.replace(/\s+/g, ' ').trim();
|
|
222
|
-
return oneLine.length <= 60 ? oneLine : `${oneLine.slice(0, 60)}...`;
|
|
223
|
-
}
|