mingdao-harness 0.1.54
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/LICENSE +21 -0
- package/README.md +246 -0
- package/assets/tokenizer-data.json.gz +0 -0
- package/docs/ARCHITECTURE.md +108 -0
- package/docs/CONFIG.md +257 -0
- package/docs/DESKTOP-EVALUATION.md +48 -0
- package/docs/PROVIDERS.md +98 -0
- package/docs/QA-REPORT.md +333 -0
- package/install.bat +8 -0
- package/install.ps1 +57 -0
- package/install.sh +154 -0
- package/package.json +61 -0
- package/skills/api-design/SKILL.md +21 -0
- package/skills/code-review/SKILL.md +31 -0
- package/skills/debugging/SKILL.md +21 -0
- package/skills/docker/SKILL.md +27 -0
- package/skills/docx/SKILL.md +28 -0
- package/skills/frontend-design/SKILL.md +31 -0
- package/skills/git-commit/SKILL.md +32 -0
- package/skills/pdf/SKILL.md +30 -0
- package/skills/pptx/SKILL.md +24 -0
- package/skills/refactoring/SKILL.md +24 -0
- package/skills/release-checklist/SKILL.md +20 -0
- package/skills/testing/SKILL.md +30 -0
- package/skills/webapp-testing/SKILL.md +27 -0
- package/skills/xlsx/SKILL.md +28 -0
- package/src/agent.js +472 -0
- package/src/audit.js +68 -0
- package/src/autostart.js +74 -0
- package/src/batch.js +182 -0
- package/src/cachestats.js +215 -0
- package/src/cli.js +1099 -0
- package/src/commands/key.js +75 -0
- package/src/commands/schedule.js +176 -0
- package/src/commands/skill.js +168 -0
- package/src/commands/sync.js +222 -0
- package/src/commands/update.js +157 -0
- package/src/commands/workspace.js +109 -0
- package/src/compact.js +112 -0
- package/src/config.js +134 -0
- package/src/context.js +86 -0
- package/src/cost-guard.js +58 -0
- package/src/credentials.js +69 -0
- package/src/hooks.js +123 -0
- package/src/index.js +42 -0
- package/src/mcp-presets.js +78 -0
- package/src/mcp.js +284 -0
- package/src/memory.js +242 -0
- package/src/model-discovery.js +153 -0
- package/src/models.js +186 -0
- package/src/notify.js +38 -0
- package/src/permissions.js +83 -0
- package/src/pricing.js +156 -0
- package/src/prompts.js +58 -0
- package/src/providers/index.js +135 -0
- package/src/providers/openai-compatible.js +171 -0
- package/src/routing.js +126 -0
- package/src/schedule.js +434 -0
- package/src/session-index.js +122 -0
- package/src/session.js +131 -0
- package/src/skill-lib.js +350 -0
- package/src/skill-registry.js +165 -0
- package/src/skills.js +135 -0
- package/src/sync-server.js +564 -0
- package/src/sync.js +479 -0
- package/src/tasks.js +126 -0
- package/src/titles.js +75 -0
- package/src/tokenizer.js +287 -0
- package/src/tools/bash.js +165 -0
- package/src/tools/fs-tools.js +346 -0
- package/src/tools/index.js +287 -0
- package/src/ui.js +643 -0
- package/src/update.js +222 -0
- package/src/web/attachments.js +49 -0
- package/src/web/index.html +993 -0
- package/src/web/server.js +1173 -0
- package/src/web/web-io.js +107 -0
- package/src/workspace.js +157 -0
package/src/agent.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Agent 核心循环:消息 → 模型(流式)→ PreToolUse 钩子 → 权限引擎 → 工具执行
|
|
2
|
+
// → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
|
|
3
|
+
// 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
|
|
4
|
+
|
|
5
|
+
import { trimMessages, clampText } from './context.js';
|
|
6
|
+
import { compactConversation } from './compact.js';
|
|
7
|
+
import { toolSchemas, dispatch } from './tools/index.js';
|
|
8
|
+
import { modelPreset } from './models.js';
|
|
9
|
+
import { makeTokenCounter } from './tokenizer.js';
|
|
10
|
+
import { createHooks } from './hooks.js';
|
|
11
|
+
import { createIO, style, C } from './ui.js';
|
|
12
|
+
import { subagentModel } from './routing.js';
|
|
13
|
+
import { writeAudit, redactSecrets } from './audit.js';
|
|
14
|
+
import { checkCostGuard } from './cost-guard.js';
|
|
15
|
+
|
|
16
|
+
const MAX_STEPS = 24;
|
|
17
|
+
const SUBAGENT_MAX_STEPS = 12;
|
|
18
|
+
|
|
19
|
+
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
|
|
20
|
+
const preset = modelPreset(modelName) || {};
|
|
21
|
+
const budget = cfg.contextBudget || preset.budgetTokens || 128000;
|
|
22
|
+
const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
|
|
23
|
+
const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
|
|
24
|
+
const hooks = createHooks(cfg.hooks, workingDir);
|
|
25
|
+
const todos = [];
|
|
26
|
+
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
27
|
+
const undo = undoStore || { backups: new Map() };
|
|
28
|
+
const stepLimit = maxSteps || MAX_STEPS;
|
|
29
|
+
// 只读工具集合(子代理只读模式 + 并行批次共用)
|
|
30
|
+
const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill']);
|
|
31
|
+
// 精确 token 计数:DeepSeek 词表,其他模型回退启发式
|
|
32
|
+
const count = makeTokenCounter(modelName);
|
|
33
|
+
// MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
|
|
34
|
+
const mcpSchemas = () => (mcp ? mcp.toolSchemas() : []);
|
|
35
|
+
|
|
36
|
+
// 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
|
|
37
|
+
async function spawnTask(prompt, { description = '', readOnly = false } = {}) {
|
|
38
|
+
const subIo = createIO({ quiet: true });
|
|
39
|
+
// 只读子代理(评估 A4:可并行):只读工具自动放行、写类直接拒绝,无交互询问
|
|
40
|
+
const subPermission = readOnly
|
|
41
|
+
? { mode: 'readonly-noask', check: (name) => READONLY_TOOLS_SET.has(name) }
|
|
42
|
+
: { check: (name, args) => permission.check(name, args, '(子任务)') };
|
|
43
|
+
// 自动路由:子代理固定走 executor 模型(便宜的执行单元)
|
|
44
|
+
const subModel = subagentModel(cfg, modelName);
|
|
45
|
+
const subAgent = createAgent({
|
|
46
|
+
provider,
|
|
47
|
+
permission: subPermission,
|
|
48
|
+
io: subIo,
|
|
49
|
+
modelName: subModel,
|
|
50
|
+
workingDir,
|
|
51
|
+
cfg: { ...cfg, contextBudget: Math.min(budget, 64000) },
|
|
52
|
+
undoStore: undo,
|
|
53
|
+
maxSteps: SUBAGENT_MAX_STEPS,
|
|
54
|
+
mcp,
|
|
55
|
+
sessionRef, // 子代理的审计记录归入主会话
|
|
56
|
+
});
|
|
57
|
+
const sys =
|
|
58
|
+
`你是主智能体 MingDao 派出的子代理,独立完成一项子任务。` +
|
|
59
|
+
`你与主线程共享同一台电脑与项目(工作目录:${workingDir})。` +
|
|
60
|
+
(readOnly ? `本次为只读调研任务:只能使用 read/ls/glob/grep/skill 工具,不得写入或执行命令。` : '') +
|
|
61
|
+
`完成后用简洁中文汇报结果、关键结论与涉及的文件路径;不要向用户提问。`;
|
|
62
|
+
const messages = [
|
|
63
|
+
{ role: 'system', content: sys },
|
|
64
|
+
{ role: 'user', content: prompt },
|
|
65
|
+
];
|
|
66
|
+
io.print(style(` ↳ 子任务${description ? ':' + description : ''}`, C.magenta));
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
const res = await subAgent.runTurn(messages);
|
|
69
|
+
const ms = Date.now() - t0;
|
|
70
|
+
const text = res.text || (res.truncated ? '(子任务达到步骤上限,未完成)' : '(子任务无输出)');
|
|
71
|
+
io.print(style(` ↳ 子任务完成(${ms}ms)`, C.magenta));
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeCtx() {
|
|
76
|
+
return {
|
|
77
|
+
cwd: workingDir,
|
|
78
|
+
io,
|
|
79
|
+
workingDir,
|
|
80
|
+
modelName,
|
|
81
|
+
provider,
|
|
82
|
+
permission,
|
|
83
|
+
cfg,
|
|
84
|
+
budget,
|
|
85
|
+
todos,
|
|
86
|
+
undoStore: undo,
|
|
87
|
+
spawnTask: (prompt, opts) => spawnTask(prompt, opts),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runTurn(messages) {
|
|
92
|
+
let steps = 0;
|
|
93
|
+
let finish = null;
|
|
94
|
+
const usage = { prompt_tokens: 0, completion_tokens: 0 };
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
// 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
|
|
97
|
+
let llmMsTotal = 0;
|
|
98
|
+
let toolMsTotal = 0;
|
|
99
|
+
let firstTokenAt = null;
|
|
100
|
+
const perf = () => ({ llmMs: llmMsTotal, toolMs: toolMsTotal, firstTokenMs: firstTokenAt == null ? null : firstTokenAt - startedAt, steps });
|
|
101
|
+
let aborted = false;
|
|
102
|
+
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
103
|
+
let currentAc = null;
|
|
104
|
+
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
105
|
+
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
106
|
+
const ctx = makeCtx();
|
|
107
|
+
const stripOrphanCalls = () => {
|
|
108
|
+
const last = messages[messages.length - 1];
|
|
109
|
+
if (last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length) {
|
|
110
|
+
messages[messages.length - 1] = { ...last, tool_calls: undefined };
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
while (steps < stepLimit) {
|
|
115
|
+
steps += 1;
|
|
116
|
+
// 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
|
|
117
|
+
// 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
|
|
118
|
+
if (cfg.autoCompact !== false) {
|
|
119
|
+
try {
|
|
120
|
+
const compacted = await compactConversation({
|
|
121
|
+
messages,
|
|
122
|
+
budget,
|
|
123
|
+
count,
|
|
124
|
+
provider,
|
|
125
|
+
executorModel: subagentModel(cfg, modelName),
|
|
126
|
+
triggerRatio: cfg.compactTrigger, // 可配置触发线(默认 80%)
|
|
127
|
+
});
|
|
128
|
+
if (compacted) {
|
|
129
|
+
messages.splice(0, messages.length, ...compacted.messages);
|
|
130
|
+
if (compacted.usage) {
|
|
131
|
+
usage.prompt_tokens += compacted.usage.prompt_tokens || 0;
|
|
132
|
+
usage.completion_tokens += compacted.usage.completion_tokens || 0;
|
|
133
|
+
}
|
|
134
|
+
io.print(
|
|
135
|
+
style(
|
|
136
|
+
`♻ 自动压缩上下文:${compacted.droppedCount} 条早期消息 → 摘要(回收约 ${compacted.droppedTokens} tokens)`,
|
|
137
|
+
C.dim
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
try {
|
|
141
|
+
onCompact?.(messages);
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
const trimmed = trimMessages(messages, budget, count);
|
|
147
|
+
|
|
148
|
+
// 费用护栏(A2):每轮开始前按今日实际费用检查;block 时暂停本轮并明确告知
|
|
149
|
+
if (cfg.costGuard) {
|
|
150
|
+
const guard = checkCostGuard();
|
|
151
|
+
if (guard) {
|
|
152
|
+
if (guard.blocked) {
|
|
153
|
+
stripOrphanCalls();
|
|
154
|
+
return {
|
|
155
|
+
text: null,
|
|
156
|
+
reasoning: '',
|
|
157
|
+
usage,
|
|
158
|
+
steps,
|
|
159
|
+
finish,
|
|
160
|
+
truncated: false,
|
|
161
|
+
aborted: false,
|
|
162
|
+
note: guard.message,
|
|
163
|
+
durationMs: Date.now() - startedAt,
|
|
164
|
+
perf: perf(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
io.print(style(guard.message, C.yellow));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ac = new AbortController();
|
|
172
|
+
currentAc = ac;
|
|
173
|
+
io.beginTurn();
|
|
174
|
+
io.startSpinner('正在思考…');
|
|
175
|
+
|
|
176
|
+
let res;
|
|
177
|
+
try {
|
|
178
|
+
const llmT0 = Date.now();
|
|
179
|
+
res = await provider.chat({
|
|
180
|
+
model: modelName,
|
|
181
|
+
messages: trimmed,
|
|
182
|
+
tools: [...toolSchemas(), ...mcpSchemas()],
|
|
183
|
+
temperature,
|
|
184
|
+
maxTokens: maxOutput,
|
|
185
|
+
signal: ac.signal,
|
|
186
|
+
onDelta(d) {
|
|
187
|
+
io.stopSpinner();
|
|
188
|
+
if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
|
|
189
|
+
if (d.text) io.writeText(d.text);
|
|
190
|
+
if (d.reasoning) io.writeReasoning(d.reasoning);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
llmMsTotal += Date.now() - llmT0;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
llmMsTotal += Date.now() - llmT0;
|
|
196
|
+
io.stopSpinner();
|
|
197
|
+
io.endTurn();
|
|
198
|
+
if (aborted) {
|
|
199
|
+
stripOrphanCalls();
|
|
200
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
finish = res.finish ?? finish;
|
|
206
|
+
if (res.usage) {
|
|
207
|
+
usage.prompt_tokens += res.usage.prompt_tokens || 0;
|
|
208
|
+
usage.completion_tokens += res.usage.completion_tokens || 0;
|
|
209
|
+
// 保留 DeepSeek 缓存命中/未命中字段(费用估算与命中率展示依赖)
|
|
210
|
+
if (Number.isFinite(res.usage.prompt_cache_hit_tokens)) {
|
|
211
|
+
usage.prompt_cache_hit_tokens = (usage.prompt_cache_hit_tokens || 0) + res.usage.prompt_cache_hit_tokens;
|
|
212
|
+
}
|
|
213
|
+
if (Number.isFinite(res.usage.prompt_cache_miss_tokens)) {
|
|
214
|
+
usage.prompt_cache_miss_tokens = (usage.prompt_cache_miss_tokens || 0) + res.usage.prompt_cache_miss_tokens;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (res.toolCalls?.length) {
|
|
219
|
+
io.endTurn();
|
|
220
|
+
const assistantMsg = { role: 'assistant', content: res.text || null, tool_calls: res.toolCalls };
|
|
221
|
+
messages.push(assistantMsg);
|
|
222
|
+
|
|
223
|
+
// 只读工具并行(P2-8):同一 response 里的连续 read/ls/glob/grep 无相互依赖,
|
|
224
|
+
// 在 auto 权限模式下(无交互询问、无副作用的纯读)Promise.all 并发执行;
|
|
225
|
+
// 其余模式/工具保持串行,避免多个权限对话框交错。事件顺序(start/render/post/回填)不变。
|
|
226
|
+
const READONLY_BATCH = new Set(['read', 'ls', 'glob', 'grep']);
|
|
227
|
+
const canBatch = permission.mode === 'auto';
|
|
228
|
+
|
|
229
|
+
// 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
|
|
230
|
+
// task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
|
|
231
|
+
async function prepTool(tc) {
|
|
232
|
+
const name = tc.function?.name || '';
|
|
233
|
+
// 审计(P3-5):参数与拒绝原因都记录(配置 audit:false 可关)
|
|
234
|
+
const auditOn = cfg.audit !== false;
|
|
235
|
+
const auditArgs = () => redactSecrets(JSON.stringify(args ?? {})).slice(0, 2000);
|
|
236
|
+
const auditEntry = (extra = {}) =>
|
|
237
|
+
writeAudit({
|
|
238
|
+
at: Date.now(),
|
|
239
|
+
session: sessionRef?.name ?? null,
|
|
240
|
+
model: modelName,
|
|
241
|
+
tool: name,
|
|
242
|
+
args: auditArgs(),
|
|
243
|
+
...extra,
|
|
244
|
+
});
|
|
245
|
+
let args = null;
|
|
246
|
+
try {
|
|
247
|
+
args = JSON.parse(tc.function?.arguments || '{}');
|
|
248
|
+
} catch {
|
|
249
|
+
// 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
|
|
250
|
+
io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
|
|
251
|
+
if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
|
|
252
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: '工具参数 JSON 解析失败,请重新输出合法参数。' });
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
|
|
256
|
+
|
|
257
|
+
// PreToolUse 钩子
|
|
258
|
+
const hook = await hooks.pre(name, args);
|
|
259
|
+
if (hook.decision === 'block') {
|
|
260
|
+
io.renderToolDenied(name, args, '被钩子阻止');
|
|
261
|
+
if (auditOn) auditEntry({ denied: true, reason: `PreToolUse 钩子阻止:${String(hook.reason || '').slice(0, 200)}` });
|
|
262
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具被 PreToolUse 钩子阻止:${hook.reason}` });
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isMcp = name.startsWith('mcp__');
|
|
267
|
+
let allowed = false;
|
|
268
|
+
if (isMcp && mcp?.isReadonly(name)) {
|
|
269
|
+
allowed = true; // MCP 工具的只读标注自动放行
|
|
270
|
+
} else {
|
|
271
|
+
try {
|
|
272
|
+
allowed = await permission.check(name, args);
|
|
273
|
+
} catch {
|
|
274
|
+
allowed = false; // 交互通道异常(如 stdin EOF)时按拒绝处理,不中断整个回合
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!allowed) {
|
|
278
|
+
io.renderToolDenied(name, args, '未授权');
|
|
279
|
+
if (auditOn) auditEntry({ denied: true, reason: '未授权' });
|
|
280
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: '用户拒绝了该工具的执行权限。' });
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return { tc, name, args, isMcp };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
|
|
287
|
+
async function runTool(prep) {
|
|
288
|
+
io.renderToolStart?.(prep.name, prep.args);
|
|
289
|
+
try {
|
|
290
|
+
if (prep.isMcp) {
|
|
291
|
+
if (!mcp) throw new Error('MCP 工具未启用');
|
|
292
|
+
return await mcp.call(prep.name, prep.args);
|
|
293
|
+
}
|
|
294
|
+
return await dispatch(prep.name, prep.args, ctx);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return JSON.stringify({ ok: false, error: String(err?.message || err) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 收尾:渲染结果 → todo 更新 → PostToolUse 钩子 → 回填消息(顺序与串行一致)
|
|
301
|
+
function finishTool(prep, result, t0) {
|
|
302
|
+
const ms = Date.now() - t0;
|
|
303
|
+
toolMsTotal += ms;
|
|
304
|
+
io.renderTool(prep.name, prep.args, result, ms);
|
|
305
|
+
if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
|
|
306
|
+
hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
|
|
307
|
+
// 审计(P3-5):执行结果摘要(含退出码/超时/输出大小)
|
|
308
|
+
if (cfg.audit !== false) {
|
|
309
|
+
let rObj = result;
|
|
310
|
+
if (typeof result === 'string') {
|
|
311
|
+
try {
|
|
312
|
+
rObj = JSON.parse(result);
|
|
313
|
+
} catch {
|
|
314
|
+
rObj = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
writeAudit({
|
|
318
|
+
at: Date.now(),
|
|
319
|
+
session: sessionRef?.name ?? null,
|
|
320
|
+
model: modelName,
|
|
321
|
+
tool: prep.name,
|
|
322
|
+
args: redactSecrets(JSON.stringify(prep.args ?? {})).slice(0, 2000),
|
|
323
|
+
denied: false,
|
|
324
|
+
ok: rObj ? rObj.ok !== false : !String(result ?? '').includes('"ok": false'),
|
|
325
|
+
exitCode: rObj?.exitCode ?? null,
|
|
326
|
+
timedOut: Boolean(rObj?.timedOut),
|
|
327
|
+
durationMs: ms,
|
|
328
|
+
outputBytes: Buffer.byteLength(typeof result === 'string' ? result : JSON.stringify(result ?? {}), 'utf8'),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
332
|
+
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: clampText(text) });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let i = 0;
|
|
336
|
+
while (i < res.toolCalls.length) {
|
|
337
|
+
// 收集批次:连续且可并行的只读工具成批;首个非并行项(写入类/被拒/MCP)结束批次
|
|
338
|
+
const batch = []; // {prep, batchable}
|
|
339
|
+
while (i < res.toolCalls.length) {
|
|
340
|
+
const tc = res.toolCalls[i];
|
|
341
|
+
const prep = await prepTool(tc);
|
|
342
|
+
const name = tc.function?.name || '';
|
|
343
|
+
let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
|
|
344
|
+
if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
|
|
345
|
+
batch.push({ prep, batchable });
|
|
346
|
+
i += 1;
|
|
347
|
+
if (!batchable) break;
|
|
348
|
+
}
|
|
349
|
+
const firstNon = batch.findIndex((b) => !b.batchable);
|
|
350
|
+
const prefix = firstNon === -1 ? batch : batch.slice(0, firstNon); // 审计 B11:非并行项前的前导只读子批仍可并行
|
|
351
|
+
const allBatchable = prefix.length > 1 && prefix.every((b) => b.batchable);
|
|
352
|
+
if (allBatchable && prefix.length === batch.length) {
|
|
353
|
+
// 纯只读批次:并行执行(顺序收集结果,UI 事件顺序不变);per-tool 计时(审计 B3)
|
|
354
|
+
const results = await Promise.all(
|
|
355
|
+
batch.map((b) => {
|
|
356
|
+
const t0 = Date.now();
|
|
357
|
+
return runTool(b.prep).then((r) => ({ r, t0 }));
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
batch.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
|
|
361
|
+
} else if (allBatchable && prefix.length > 1) {
|
|
362
|
+
// 前导只读子批并行 + 其余串行
|
|
363
|
+
const results = await Promise.all(
|
|
364
|
+
prefix.map((b) => {
|
|
365
|
+
const t0 = Date.now();
|
|
366
|
+
return runTool(b.prep).then((r) => ({ r, t0 }));
|
|
367
|
+
})
|
|
368
|
+
);
|
|
369
|
+
prefix.forEach((b, idx) => finishTool(b.prep, results[idx].r, results[idx].t0));
|
|
370
|
+
for (const b of batch.slice(prefix.length)) {
|
|
371
|
+
if (!b.prep) continue;
|
|
372
|
+
const t0 = Date.now();
|
|
373
|
+
finishTool(b.prep, await runTool(b.prep), t0);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
for (const b of batch) {
|
|
377
|
+
if (!b.prep) continue; // 拒绝/失败成员已在 prepTool 回填
|
|
378
|
+
const t0 = Date.now();
|
|
379
|
+
finishTool(b.prep, await runTool(b.prep), t0);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
io.endTurn();
|
|
385
|
+
// 最终纯文本回复回填消息历史(会话持久化与多轮上下文依赖它);
|
|
386
|
+
// 空文本不回填,避免个别 API 对 assistant 空 content 报错
|
|
387
|
+
if (res.text) {
|
|
388
|
+
messages.push({ role: 'assistant', content: res.text });
|
|
389
|
+
}
|
|
390
|
+
// 输出被长度上限截断(DeepSeek 推理吃满 maxOutput 时正文为空):让模型从断点续写,绝不静默结束。
|
|
391
|
+
// 空轮护栏(评估 4.2-2):每轮空输出都是全额 completion 计费(pro 32k 闲时 ≈ ¥0.43/轮),
|
|
392
|
+
// 上限从 12 收紧到 3(cfg.maxEmptyRounds 可调),超限直接终止并明确提示。
|
|
393
|
+
const maxEmptyRounds = Math.max(1, Number(cfg.maxEmptyRounds) || 3);
|
|
394
|
+
if (res.finish === 'length') {
|
|
395
|
+
emptyRounds += 1;
|
|
396
|
+
if (emptyRounds >= maxEmptyRounds) {
|
|
397
|
+
stripOrphanCalls();
|
|
398
|
+
return {
|
|
399
|
+
text: res.text || null,
|
|
400
|
+
reasoning: res.reasoning || '',
|
|
401
|
+
usage,
|
|
402
|
+
steps,
|
|
403
|
+
finish,
|
|
404
|
+
truncated: true,
|
|
405
|
+
aborted: false,
|
|
406
|
+
note: `模型连续 ${maxEmptyRounds} 轮输出被截断,已停止续写——请换用更长输出的模型或拆分任务(maxEmptyRounds 可调)。`,
|
|
407
|
+
durationMs: Date.now() - startedAt,
|
|
408
|
+
perf: perf(),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
messages.push({
|
|
412
|
+
role: 'user',
|
|
413
|
+
content: '(系统提示)你的上一条输出因达到长度上限被截断。请直接继续未完成的部分:不要重复已写内容,从断点接着完成。',
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!res.text) {
|
|
418
|
+
// 静默空输出(无工具无正文):同样回填续写提示,避免界面"没动静"(审计 Q1:与截断续写统一护栏)
|
|
419
|
+
emptyRounds += 1;
|
|
420
|
+
if (emptyRounds >= maxEmptyRounds) {
|
|
421
|
+
return {
|
|
422
|
+
text: null,
|
|
423
|
+
reasoning: res.reasoning || '',
|
|
424
|
+
usage,
|
|
425
|
+
steps,
|
|
426
|
+
finish,
|
|
427
|
+
truncated: false,
|
|
428
|
+
aborted: false,
|
|
429
|
+
note: '模型本轮没有输出正文。',
|
|
430
|
+
durationMs: Date.now() - startedAt,
|
|
431
|
+
perf: perf(),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
messages.push({
|
|
435
|
+
role: 'user',
|
|
436
|
+
content: '(系统提示)你刚才没有输出任何正文就结束了。请继续完成用户的任务,给出实际内容。',
|
|
437
|
+
});
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
text: res.text || '',
|
|
442
|
+
reasoning: res.reasoning || '',
|
|
443
|
+
usage,
|
|
444
|
+
steps,
|
|
445
|
+
finish,
|
|
446
|
+
truncated: false,
|
|
447
|
+
aborted: false,
|
|
448
|
+
durationMs: Date.now() - startedAt,
|
|
449
|
+
perf: perf(),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
io.endTurn();
|
|
454
|
+
// 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
|
|
455
|
+
stripOrphanCalls();
|
|
456
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
|
|
457
|
+
} finally {
|
|
458
|
+
currentAc = null;
|
|
459
|
+
offSigint();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
modelName,
|
|
465
|
+
budget,
|
|
466
|
+
maxOutput,
|
|
467
|
+
temperature,
|
|
468
|
+
runTurn,
|
|
469
|
+
spawnTask: (prompt, opts) => spawnTask(prompt, opts),
|
|
470
|
+
getTodos: () => todos.slice(),
|
|
471
|
+
};
|
|
472
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 工具调用审计日志(P3-5,第三轮复审建议):
|
|
2
|
+
// - 每个工具调用(含被拒/被钩子阻止/参数解析失败)落 ~/.mingdao/audit.jsonl(600 权限)
|
|
3
|
+
// - 纯追加 + 低频截断(>20000 行保留最近 10000 行),与 journal 同款策略
|
|
4
|
+
// - 记录内容做轻量脱敏(sk- 开头的 Key 掩码);config.audit=false 可关闭(默认开)
|
|
5
|
+
// - mingdao audit [数量] 查看最近记录(倒序)
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
|
+
|
|
11
|
+
const MAX_LINES = 20000;
|
|
12
|
+
const KEEP_LINES = 10000;
|
|
13
|
+
let auditCount = 0; // 内存计数(评估 P3-2):避免每次写入都整文件读一遍只为查行数
|
|
14
|
+
|
|
15
|
+
export function auditFile() {
|
|
16
|
+
return path.join(mingdaoHome(), 'audit.jsonl');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 轻量脱敏:sk- 系 API Key 掩码(审计日志可安全共享排查)
|
|
20
|
+
export function redactSecrets(text) {
|
|
21
|
+
return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeAudit(entry) {
|
|
25
|
+
try {
|
|
26
|
+
ensureHome();
|
|
27
|
+
const file = auditFile();
|
|
28
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
29
|
+
try {
|
|
30
|
+
fs.chmodSync(file, 0o600);
|
|
31
|
+
} catch {}
|
|
32
|
+
auditCount += 1;
|
|
33
|
+
} catch {
|
|
34
|
+
return; // 审计失败绝不影响会话
|
|
35
|
+
}
|
|
36
|
+
// 低频截断:跨过上限后每 200 条才读盘检查一次
|
|
37
|
+
if (auditCount > MAX_LINES && auditCount % 200 === 0) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(auditFile(), 'utf8');
|
|
40
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
41
|
+
if (lines.length > MAX_LINES) {
|
|
42
|
+
fs.writeFileSync(auditFile(), lines.slice(-KEEP_LINES).join('\n') + '\n', { mode: 0o600 });
|
|
43
|
+
auditCount = KEEP_LINES;
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listAudit(limit = 20) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(auditFile(), 'utf8');
|
|
52
|
+
return raw
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((l) => {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(l);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.slice(-Math.max(1, Math.min(Number(limit) || 20, 500)))
|
|
64
|
+
.reverse();
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/autostart.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// 开机自启:随登录自动启动 MingDao WebUI 服务器(跨平台、零依赖)。
|
|
2
|
+
// - Linux:~/.config/autostart/mingdao-web.desktop(XDG Autostart)
|
|
3
|
+
// - Windows:启动文件夹 .bat
|
|
4
|
+
// - macOS:~/Library/LaunchAgents/org.mingdao.web.plist
|
|
5
|
+
// 命令:mingdao autostart on|off|status(WebUI 设置面板同款开关)
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
function linuxFile() {
|
|
13
|
+
return path.join(os.homedir(), '.config', 'autostart', 'mingdao-web.desktop');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function windowsFile() {
|
|
17
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
18
|
+
return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'mingdao-autostart.bat');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function macFile() {
|
|
22
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', 'org.mingdao.web.plist');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function autostartPath() {
|
|
26
|
+
if (process.platform === 'win32') return windowsFile();
|
|
27
|
+
if (process.platform === 'darwin') return macFile();
|
|
28
|
+
return linuxFile();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function autostartStatus() {
|
|
32
|
+
return fs.existsSync(autostartPath());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function enableAutostart() {
|
|
36
|
+
const target = autostartPath();
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
39
|
+
if (process.platform === 'win32') {
|
|
40
|
+
fs.writeFileSync(target, '@echo off\r\nstart "" cmd /c "mingdao web 3820"\r\n');
|
|
41
|
+
} else if (process.platform === 'darwin') {
|
|
42
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
43
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
44
|
+
<plist version="1.0"><dict>
|
|
45
|
+
<key>Label</key><string>org.mingdao.web</string>
|
|
46
|
+
<key>ProgramArguments</key><array>
|
|
47
|
+
<string>/bin/sh</string><string>-c</string><string>mingdao web 3820</string>
|
|
48
|
+
</array>
|
|
49
|
+
<key>RunAtLoad</key><true/>
|
|
50
|
+
<key>StandardOutPath</key><string>/tmp/mingdao-web.log</string>
|
|
51
|
+
<key>StandardErrorPath</key><string>/tmp/mingdao-web.err</string>
|
|
52
|
+
</dict></plist>
|
|
53
|
+
`;
|
|
54
|
+
fs.writeFileSync(target, plist);
|
|
55
|
+
} else {
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
target,
|
|
58
|
+
'[Desktop Entry]\nType=Application\nName=MingDao 自动启动\nComment=MingDao-Harness WebUI 服务器(登录时启动)\nExec=sh -c \'mingdao web 3820 >/dev/null 2>&1\'\nX-GNOME-Autostart-enabled=true\nHidden=false\n'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function disableAutostart() {
|
|
68
|
+
try {
|
|
69
|
+
if (fs.existsSync(autostartPath())) fs.unlinkSync(autostartPath());
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|