dsh-mcmp 2.1.4 → 2.2.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 +3 -1
- package/lib/index.js +131 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
|
|
|
19
19
|
- **成果全落盘**:每步迭代完整追加保存(含质疑清单),旧内容绝不覆盖;代码保留全部历史版本;全新运行时旧目录自动归档为 `数学建模流水线_历史归档_*`
|
|
20
20
|
- **实时进度面板**:独立浮动窗口(可拖拽/最小化/关闭),显示百分比进度条、当前轮/步骤/迭代、8 步骤打点、日志、产出文件,支持一键中止、重置(全新开始)
|
|
21
21
|
- **断点续跑与重置**:中断/关闭窗口不丢进度,再次 `/loopbegin` 自动续跑;`/loopreset` 或面板「重置(全新开始)」清空续跑记录,下次从头跑
|
|
22
|
+
- **迭代失败自动重试**:单个子任务因模型限流/过载(429、5xx)等瞬时故障失败时,自动退避重试(默认 15s/45s,最多 2 次),吸收抖动、避免"连续 3 败提前终止";确实持续失败才终止,并提示排查方向
|
|
23
|
+
- **`--from` 文件自动补全**:`--from 文件名` 支持部分匹配,自动查找工作区根目录下的 `.md` 文档(前缀优先、包含次之;唯一命中即采用,歧义列出候选);只写 `--from` 会列出全部 MD 文档供选择——不用再复制粘贴完整文件名
|
|
22
24
|
- **原生工作流卡片**:聊天区实时显示每个子智能体的运行状态(按阶段分组)
|
|
23
25
|
|
|
24
26
|
## 八大步骤(每轮 19 次迭代)
|
|
@@ -42,7 +44,7 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
|
|
|
42
44
|
2. **触发插件**:发送一条以 `/loopbegin` 开头的消息:
|
|
43
45
|
- `/loopbegin` —— 默认 1 轮
|
|
44
46
|
- `/loopbegin --round=3` —— 3 轮外循环(上限 10)
|
|
45
|
-
- `/loopbegin --from 题目.txt` ——
|
|
47
|
+
- `/loopbegin --from 题目.txt` —— 从文件读取赛题。**支持部分匹配自动补全**:只写 `--from` 会列出根目录全部 MD 文档;`--from 2024` 会自动匹配到 `2024 年高教社杯全国大学生数学建模竞赛题目.md`(唯一命中即用,歧义列出候选)
|
|
46
48
|
3. **查看进度**:右下角浮动面板(百分比、步骤打点、日志、文件)+ 聊天区工作流卡片。
|
|
47
49
|
|
|
48
50
|
辅助命令:`/loopstatus`(查看进度)、`/loopabort`(中止,已产出成果保留)、`/loopreset`(清空状态与断点续跑记录,下次 `/loopbegin` 从头全新开始;等价于面板「重置(全新开始)」按钮)。
|
package/lib/index.js
CHANGED
|
@@ -29,12 +29,38 @@ export function apply(ctx) {
|
|
|
29
29
|
const subagents = ctx.get('subagents')
|
|
30
30
|
const systemPrompt = ctx.get('systemPrompt')
|
|
31
31
|
const webServer = ctx.get('webServer')
|
|
32
|
-
|
|
32
|
+
const timerSvc = ctx.get('timer')
|
|
33
|
+
console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer + ' timer=' + !!timerSvc)
|
|
33
34
|
|
|
34
35
|
const FLOW_NAME = '数学建模论文流水线v2'
|
|
35
36
|
// 全流水线缺陷台账:跨步骤错误登记、裁决与向上修复的唯一权威文件
|
|
36
37
|
const LEDGER = '00_缺陷与裁决台账.md'
|
|
37
38
|
|
|
39
|
+
// 迭代级重试:一次迭代失败(如模型 429/5xx 瞬时抖动)后自动退避重试,
|
|
40
|
+
// 避免"连续 3 个子任务失败"被瞬时故障触发。重试间隔可用 --retry-ms=N 覆盖(测试/调优用)。
|
|
41
|
+
const DEFAULT_RETRY_DELAYS = [15000, 45000]
|
|
42
|
+
const MAX_ITER_RETRY = 2
|
|
43
|
+
|
|
44
|
+
function sleepAbortable(ms, signal) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false
|
|
47
|
+
let timerDispose = null
|
|
48
|
+
const finish = () => {
|
|
49
|
+
if (settled) return
|
|
50
|
+
settled = true
|
|
51
|
+
if (timerDispose) { try { timerDispose() } catch (err) { /* ignore */ } }
|
|
52
|
+
resolve()
|
|
53
|
+
}
|
|
54
|
+
if (signal && signal.aborted) { resolve(); return }
|
|
55
|
+
if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', finish, { once: true })
|
|
56
|
+
if (timerSvc && typeof timerSvc.timeout === 'function') {
|
|
57
|
+
try { timerDispose = timerSvc.timeout(finish, ms) } catch (err) { finish() }
|
|
58
|
+
} else {
|
|
59
|
+
finish() // 无 timer 服务时不做等待,直接重试
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
// ---------- 识图能力探测(宿主侧,仅供参考;以子智能体自检为准) ----------
|
|
39
65
|
function detectVision() {
|
|
40
66
|
// 1) 宿主服务:常见的识图服务键
|
|
@@ -113,6 +139,7 @@ export function apply(ctx) {
|
|
|
113
139
|
let state = freshState()
|
|
114
140
|
let active = null
|
|
115
141
|
let lastSession = null // 最近一次运行所属会话,供「清空状态」追加重置标记
|
|
142
|
+
let starting = false // 启动锁:async startPipeline 中等待目录扫描时防止并发启动
|
|
116
143
|
const recording = new Map()
|
|
117
144
|
|
|
118
145
|
function fmtTime(ts) {
|
|
@@ -289,6 +316,67 @@ export function apply(ctx) {
|
|
|
289
316
|
return filtered.trim()
|
|
290
317
|
}
|
|
291
318
|
|
|
319
|
+
// ---------- --from 的文件补全:列出/匹配工作区根目录的 .md 文档 ----------
|
|
320
|
+
async function listMdFiles(dir) {
|
|
321
|
+
if (!fs || !dir) return []
|
|
322
|
+
try {
|
|
323
|
+
const root = await fs.resolve(dir)
|
|
324
|
+
const entries = await fs.listDir(root)
|
|
325
|
+
return entries
|
|
326
|
+
.filter((e) => e && e.type === 'file' && /\.md$/i.test(e.name || ''))
|
|
327
|
+
.map((e) => e.name)
|
|
328
|
+
.sort()
|
|
329
|
+
} catch (err) { /* 目录不存在/不可读时返回空 */ }
|
|
330
|
+
return []
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function pickMdMatch(names, partial) {
|
|
334
|
+
const p = (partial || '').toLowerCase()
|
|
335
|
+
const exact = names.filter((n) => n.toLowerCase() === p)
|
|
336
|
+
if (exact.length === 1) return { kind: 'match', name: exact[0] }
|
|
337
|
+
const starts = names.filter((n) => n.toLowerCase().startsWith(p))
|
|
338
|
+
if (starts.length === 1) return { kind: 'match', name: starts[0] }
|
|
339
|
+
if (starts.length > 1) return { kind: 'ambiguous', names: starts }
|
|
340
|
+
const contains = names.filter((n) => n.toLowerCase().indexOf(p) >= 0)
|
|
341
|
+
if (contains.length === 1) return { kind: 'match', name: contains[0] }
|
|
342
|
+
if (contains.length > 1) return { kind: 'ambiguous', names: contains }
|
|
343
|
+
return { kind: 'none' }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function resolveFromPath(cwd, raw) {
|
|
347
|
+
// 返回 { kind:'ok', path } | { kind:'error', text }
|
|
348
|
+
let p = (raw || '').trim().replace(/^["']|["']$/g, '')
|
|
349
|
+
const cwdNorm = cwd.replace(/[\\/]+$/, '')
|
|
350
|
+
if (p.length === 0) {
|
|
351
|
+
const names = await listMdFiles(cwd)
|
|
352
|
+
if (names.length === 0) return { kind: 'error', text: '--from 未指定文件,且工作区根目录下没有 .md 文档。' }
|
|
353
|
+
return { kind: 'error', text: '--from 未指定文件。工作区根目录下的 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') + '\n请用 --from 文件名(支持部分匹配,如 --from 2024)重新指定。' }
|
|
354
|
+
}
|
|
355
|
+
// 绝对路径
|
|
356
|
+
if (/^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0) {
|
|
357
|
+
return { kind: 'ok', path: p }
|
|
358
|
+
}
|
|
359
|
+
// 含路径分隔符的相对路径:直接按路径解析并验证存在
|
|
360
|
+
if (p.indexOf('/') >= 0 || p.indexOf('\\') >= 0) {
|
|
361
|
+
const full = cwdNorm + '/' + p
|
|
362
|
+
try {
|
|
363
|
+
const t = await fs.resolve(full)
|
|
364
|
+
const st = await fs.stat(t)
|
|
365
|
+
if (st && st.type === 'file') return { kind: 'ok', path: full }
|
|
366
|
+
return { kind: 'error', text: '路径不是文件: ' + p }
|
|
367
|
+
} catch (err) { /* fallthrough 到模糊匹配 */ }
|
|
368
|
+
}
|
|
369
|
+
// 模糊匹配文件名
|
|
370
|
+
const names = await listMdFiles(cwd)
|
|
371
|
+
const pick = pickMdMatch(names, p)
|
|
372
|
+
if (pick.kind === 'match') return { kind: 'ok', path: cwdNorm + '/' + pick.name }
|
|
373
|
+
if (pick.kind === 'ambiguous') {
|
|
374
|
+
return { kind: 'error', text: '--from "' + p + '" 匹配到多个文档,请补充关键字:\n' + pick.names.map((n) => ' - ' + n).join('\n') }
|
|
375
|
+
}
|
|
376
|
+
const hint = names.length > 0 ? '\n工作区根目录现有 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') : ''
|
|
377
|
+
return { kind: 'error', text: '未找到匹配 "' + p + '" 的 MD 文档。' + hint }
|
|
378
|
+
}
|
|
379
|
+
|
|
292
380
|
function buildPrompt(round, si, ii, s, it, args) {
|
|
293
381
|
const p = []
|
|
294
382
|
p.push('你正在参与「全国大学生数学建模竞赛论文」的多轮自动化撰写流水线,担任本轮「' + s.name + ' · ' + it.name + '」的执行专家。你的目标不是"写完",而是产出**可直接放进竞赛论文、经得起评审质疑**的内容。')
|
|
@@ -476,10 +564,23 @@ export function apply(ctx) {
|
|
|
476
564
|
let started = false
|
|
477
565
|
let outcome = 'failed'
|
|
478
566
|
let note = ''
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
567
|
+
// 迭代级重试:失败(如模型 429/5xx 瞬时抖动)后自动退避重试,吸收瞬时故障
|
|
568
|
+
const retryDelays = Array.isArray(args.retryDelays) && args.retryDelays.length ? args.retryDelays : DEFAULT_RETRY_DELAYS
|
|
569
|
+
let res = null
|
|
570
|
+
for (let attempt = 0; ; attempt++) {
|
|
571
|
+
res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
|
|
572
|
+
if (!started) {
|
|
573
|
+
started = true
|
|
574
|
+
appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
if (res.outcome !== 'failed' || attempt >= MAX_ITER_RETRY) break
|
|
578
|
+
const delayMs = retryDelays[Math.min(attempt, retryDelays.length - 1)] || 0
|
|
579
|
+
pushLog('迭代失败,将在 ' + Math.round(delayMs / 1000) + 's 后重试(第 ' + (attempt + 1) + '/' + MAX_ITER_RETRY + ' 次): ' + label)
|
|
580
|
+
await sleepAbortable(delayMs, controller.signal)
|
|
581
|
+
if (controller.signal.aborted) { cancelled = true; break }
|
|
582
|
+
}
|
|
583
|
+
if (cancelled) break
|
|
483
584
|
outcome = res.outcome
|
|
484
585
|
note = (res.text || '').trim().slice(0, 300)
|
|
485
586
|
if (res.err) note = '执行异常: ' + String((res.err && res.err.message) || res.err).slice(0, 200)
|
|
@@ -506,7 +607,7 @@ export function apply(ctx) {
|
|
|
506
607
|
if (controller.signal.aborted) { cancelled = true; break }
|
|
507
608
|
if (failStreak >= 3) {
|
|
508
609
|
stopReason = 'error'
|
|
509
|
-
errorMsg = '连续 3
|
|
610
|
+
errorMsg = '连续 3 个子任务失败(每个失败迭代已含自动重试),流水线提前终止。常见原因:模型服务限流(429)/账户余额不足(402)/上下文过长/工作区文件问题;请排查后重新 /loopbegin 续跑,或 /loopreset 后重来。已有成果全部保留。'
|
|
510
611
|
break
|
|
511
612
|
}
|
|
512
613
|
}
|
|
@@ -540,12 +641,13 @@ export function apply(ctx) {
|
|
|
540
641
|
if (active && active.runId === runId) active = null
|
|
541
642
|
}
|
|
542
643
|
|
|
543
|
-
// ---------- 共享的流水线启动逻辑(
|
|
544
|
-
function startPipeline(agent, rawInput) {
|
|
644
|
+
// ---------- 共享的流水线启动逻辑(async:--from 需要异步扫描目录;启动锁防并发) ----------
|
|
645
|
+
async function startPipeline(agent, rawInput) {
|
|
646
|
+
if (state.status === 'running' || starting) {
|
|
647
|
+
return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
|
|
648
|
+
}
|
|
649
|
+
starting = true
|
|
545
650
|
try {
|
|
546
|
-
if (state.status === 'running') {
|
|
547
|
-
return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
|
|
548
|
-
}
|
|
549
651
|
if (!subagents) return { kind: 'error', text: '子智能体服务不可用(当前部署未提供),无法启动流水线。' }
|
|
550
652
|
const session = agent && agent.session
|
|
551
653
|
const cwd = session && session.header ? session.header.cwd : undefined
|
|
@@ -560,20 +662,21 @@ export function apply(ctx) {
|
|
|
560
662
|
if (!Number.isSafeInteger(rounds) || rounds < 1) rounds = 1
|
|
561
663
|
if (rounds > 10) return { kind: 'error', text: '--round 最大为 10。每轮 19 次迭代、每次迭代启动一个专家子智能体,轮数过大会非常耗时。' }
|
|
562
664
|
const fresh = /(?:^|\s)--fresh\b/i.test(raw)
|
|
563
|
-
//
|
|
564
|
-
const
|
|
665
|
+
// 迭代失败重试间隔(毫秒),默认 [15s, 45s];--retry-ms=N 可覆盖(测试/调优)
|
|
666
|
+
const mRetry = /(?:^|\s)--retry-ms\s*[=:]?\s*(\d+)/i.exec(raw)
|
|
667
|
+
const retryDelays = mRetry ? [Math.max(1, parseInt(mRetry[1], 10)), Math.max(1, parseInt(mRetry[1], 10))] : DEFAULT_RETRY_DELAYS
|
|
668
|
+
// --from:允许只写 --from(列出根目录 MD 文档)或部分文件名(自动匹配补全)
|
|
669
|
+
const hasFrom = /(?:^|\s)--from\b/i.test(raw)
|
|
670
|
+
const mFrom = hasFrom ? /(?:^|\s)--from(?:=)?\s*([^\r\n]+?)(?=\s+--|\r?$)/i.exec(raw) : null
|
|
565
671
|
let problem = null
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
? p
|
|
571
|
-
: cwd.replace(/[\\/]+$/, '') + '/' + p
|
|
572
|
-
problem = { kind: 'file', path: abs }
|
|
672
|
+
if (hasFrom) {
|
|
673
|
+
const resolved = await resolveFromPath(cwd, mFrom ? mFrom[1] : '')
|
|
674
|
+
if (resolved.kind === 'error') return { kind: 'error', text: resolved.text }
|
|
675
|
+
problem = { kind: 'file', path: resolved.path }
|
|
573
676
|
} else {
|
|
574
677
|
const text = extractProblem(session)
|
|
575
678
|
if (text.trim().length < 40) {
|
|
576
|
-
return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from
|
|
679
|
+
return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from 文件名(支持部分匹配自动查找根目录 .md 文档)。' }
|
|
577
680
|
}
|
|
578
681
|
problem = { kind: 'text', text: text.length > 6000 ? text.slice(0, 6000) + '\n…(原文过长,已截断)' : text }
|
|
579
682
|
}
|
|
@@ -581,7 +684,7 @@ export function apply(ctx) {
|
|
|
581
684
|
const problemPath = problem.kind === 'file' ? problem.path : wsDir + '/00_赛题原文.md'
|
|
582
685
|
const title = problem.kind === 'text' ? problem.text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0)[0] : problem.path
|
|
583
686
|
const vision = detectVision()
|
|
584
|
-
const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision }
|
|
687
|
+
const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision, retryDelays }
|
|
585
688
|
// 断点续跑:扫描本会话落盘的 v2 工作流记录,跳过已完成的迭代
|
|
586
689
|
const resumeCount = fresh ? 0 : completedIterations(session)
|
|
587
690
|
args.startIndex = resumeCount
|
|
@@ -627,6 +730,8 @@ export function apply(ctx) {
|
|
|
627
730
|
}
|
|
628
731
|
} catch (err) {
|
|
629
732
|
return { kind: 'error', text: '启动失败: ' + String((err && err.message) || err) }
|
|
733
|
+
} finally {
|
|
734
|
+
starting = false
|
|
630
735
|
}
|
|
631
736
|
}
|
|
632
737
|
|
|
@@ -636,7 +741,7 @@ export function apply(ctx) {
|
|
|
636
741
|
commands.register({
|
|
637
742
|
name: 'loopbegin',
|
|
638
743
|
description: '启动数学建模论文自动化流水线 v2(8 步骤:含图表视觉自检、评审修复闭环、论文定稿;每轮 19 次迭代、多轮外循环,成果全部落盘)',
|
|
639
|
-
input: { hint: '--round=N 外循环轮数(默认 1)
|
|
744
|
+
input: { hint: '--round=N 外循环轮数(默认 1);--from 文件名(支持部分匹配,自动查找根目录 .md 文档,如 --from 2024);--fresh 从头重跑' },
|
|
640
745
|
handler: (invocation) => startPipeline(invocation.agent, invocation.rawInput),
|
|
641
746
|
})
|
|
642
747
|
} catch (err) { console.warn('dsh-mcmp: loopbegin 注册失败(可能被其他实例占用): ' + String((err && err.message) || err)) }
|
|
@@ -694,8 +799,9 @@ export function apply(ctx) {
|
|
|
694
799
|
const agent = agents && agents.get(session.id)
|
|
695
800
|
if (!agent) return
|
|
696
801
|
console.log('dsh-mcmp: 检测到以 /loopbegin 开头的用户消息,自动启动流水线')
|
|
697
|
-
|
|
698
|
-
|
|
802
|
+
Promise.resolve(startPipeline(agent, firstLine)).then((res) => {
|
|
803
|
+
if (res && res.kind === 'error') pushLog('消息触发启动失败: ' + res.text)
|
|
804
|
+
}).catch(() => { /* 忽略 */ })
|
|
699
805
|
} catch (err) { /* 触发器自身异常忽略,不影响消息流 */ }
|
|
700
806
|
}, { global: true })
|
|
701
807
|
|
package/package.json
CHANGED