mingdao-harness 0.6.13 → 0.6.15
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.en.md +5 -4
- package/README.md +1 -1
- package/SECURITY.md +25 -12
- package/docs/CONFIG.md +108 -16
- package/docs/DEVELOPER.md +13 -7
- package/package.json +1 -1
- package/presets/local-model.json +1 -1
- package/src/agent.js +99 -21
- package/src/cli.js +10 -1
- package/src/ledger.js +22 -0
- package/src/model-discovery.js +300 -51
- package/src/presets.js +34 -9
- package/src/providers/index.js +282 -25
- package/src/tools-flow.js +92 -6
- package/src/web/app.js +2 -0
- package/src/web/server.js +84 -8
- package/presets/readonly-audit.json +0 -10
package/src/web/server.js
CHANGED
|
@@ -23,9 +23,9 @@ import { createLogWriter } from '../log-writer.js';
|
|
|
23
23
|
import { createApiDispatch } from './routes/api.js';
|
|
24
24
|
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
|
|
25
25
|
import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
|
|
26
|
-
import { availableModels, fetchProviderModels, providerHasKey, probeEndpoint, adaptiveTimeouts } from '../model-discovery.js';
|
|
26
|
+
import { availableModels, fetchProviderModels, providerHasKey, probeEndpoint, adaptiveTimeouts, probeMemoUsable } from '../model-discovery.js';
|
|
27
27
|
import { isLocalBaseUrl, resolveModelCaps } from '../model-caps.js';
|
|
28
|
-
import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport } from '../providers/index.js';
|
|
28
|
+
import { createProvider, resolveProviderConfig, helperProvider, resolveVisionSupport, setLocalQueueObserver } from '../providers/index.js';
|
|
29
29
|
import { MODELS, modelPreset, PROVIDERS } from '../models.js';
|
|
30
30
|
import { routeTask, routingConfig } from '../routing.js';
|
|
31
31
|
import { buildUserContent } from './attachments.js';
|
|
@@ -85,6 +85,13 @@ const PERM_HINT = /** @type {Record<string, string>} */ ({
|
|
|
85
85
|
auto: '不再逐次确认,工具直接执行',
|
|
86
86
|
readonly: '仅只读工具直接放行,其余逐次询问是否本次放行',
|
|
87
87
|
});
|
|
88
|
+
/**
|
|
89
|
+
* v0.6.17 §3.64:`resolveTurnPermission().source` → 账本 `run.start.permissionSource`。
|
|
90
|
+
* 两套名字必须分开:`webui` 是「本模块内部从哪来」的措辞(既有测试与 banner 都依赖它),
|
|
91
|
+
* 账本枚举是跨入口的公共契约,显式选择统一叫 `explicit`(CLI/调度同样适用)。
|
|
92
|
+
* 这里是唯一的翻译点——不要在别处再写一份映射。
|
|
93
|
+
*/
|
|
94
|
+
const LEDGER_PERMISSION_SOURCE = /** @type {Record<string, string>} */ ({ webui: 'explicit', preset: 'preset', config: 'config' });
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
97
|
* 解析「本回合的有效权限」——把"谁说了算"收成**单源纯函数**(v0.6.12,用户实测 bug)。
|
|
@@ -293,10 +300,25 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
293
300
|
// v0.6.13(A):定义提前到 provider 创建之前——本地端点的**预检**在 getProviderFor 里就要落日志,
|
|
294
301
|
// 而启动期 `await getProviderFor(cfg.model)` 早于原先的定义位置(TDZ:会直接 ReferenceError)。
|
|
295
302
|
const srvlog = createLogWriter(path.join(mingdaoHome(), 'logs', 'web-server.log'));
|
|
303
|
+
// v0.6.15(§3.59):本地端点串行化的日志必须落进 web-server.log(真机取证靠它),
|
|
304
|
+
// 而不是只打到 stderr。事件由 providers/index.js 的本地端点闸门在**入队时**发出(含在途数/并发上限)。
|
|
305
|
+
setLocalQueueObserver((/** @type {any} */ ev) => {
|
|
306
|
+
try {
|
|
307
|
+
srvlog(`${String(ev?.text || '本地端点串行化')}${ev?.label ? `(模型 ${ev.label})` : ''}`);
|
|
308
|
+
} catch {}
|
|
309
|
+
});
|
|
296
310
|
|
|
297
311
|
const providerCache = new Map();
|
|
298
312
|
// v0.6.13(A 追加):本地端点的**能力/延迟预检**结果(每次建 provider / 每轮对话开头的提示都读它)。
|
|
299
313
|
// 只对"本地端点"做:远程服务商不需要(也不该)每次开聊都多打一次请求。
|
|
314
|
+
//
|
|
315
|
+
// v0.6.13(探针误报修复)——这里的备忘**必须有 TTL,且失败结论只保鲜 60s**:
|
|
316
|
+
// 真机误报的根因就是它此前**没有任何 TTL**:进程启动时引擎没起来 → 备忘里留下
|
|
317
|
+
// 「❌ 不可达:fetch failed(本地引擎没起来?先确认端口/进程)」→ 之后每个新回合原样重放
|
|
318
|
+
// (web-server.log:413 07:03:02 那次真实探测 → :416 07:08:13 / :799 10:13:58 两次重放,
|
|
319
|
+
// 相隔 **7h10m**;10:13:58.877 `chat 开始` → .900 `chat 预检`只差 **23ms**、且没有 `预检 <模型>` 行,
|
|
320
|
+
// 正是"没重测、直接念旧结论"),而同一回合其实完全可用(:803 首帧 8.7s、:844 已跑 21m)。
|
|
321
|
+
// 结论:`probeMemoUsable()` 判保鲜期(成功 10 分钟 / 失败 60s),过期就重测。
|
|
300
322
|
const probeByModel = new Map();
|
|
301
323
|
/** 该模型是否属于"本地端点"(baseUrl 是回环/内网,或 customModels 显式声明 local:true)。 */
|
|
302
324
|
const isLocalModel = (/** @type {any} */ m) => {
|
|
@@ -307,9 +329,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
307
329
|
return false;
|
|
308
330
|
}
|
|
309
331
|
};
|
|
310
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* 取(或首次执行)预检。
|
|
334
|
+
*
|
|
335
|
+
* **探针是尽力而为:失败只降级、不阻断**(本函数自己吞掉异常 → 返回 null → 调用方按既有默认继续;
|
|
336
|
+
* 回合发起绝不受预检影响)。这里的"降级"有三层:
|
|
337
|
+
* ① 探针抛错 → 吞掉(下面 try/catch),只是"没有预检数据";
|
|
338
|
+
* ② 探针判"不可达/超时" → 仍然照常开工(状态只用于 banner/日志/自适应超时);
|
|
339
|
+
* ③ 探针慢 → 单次尝试 1.5s + 退避 + 总预算 15s 封顶(见 probeEndpoint),不会把回合发起拖成几十秒。
|
|
340
|
+
*/
|
|
311
341
|
async function ensureProbe(/** @type {any} */ m) {
|
|
312
|
-
|
|
342
|
+
const memo = probeByModel.get(m);
|
|
343
|
+
if (probeMemoUsable(memo)) return memo.value;
|
|
313
344
|
if (!isLocalModel(m)) return null;
|
|
314
345
|
let probe = null;
|
|
315
346
|
try {
|
|
@@ -326,7 +357,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
326
357
|
? `不返回 tool_calls(${probe.toolCallHits}/${probe.toolCallRuns})`
|
|
327
358
|
: '未知/不可判定';
|
|
328
359
|
srvlog(
|
|
329
|
-
`预检 ${m} ${probe.state} ttft=${probe.ttftMs == null ? 'n/a' : Math.round(probe.ttftMs) + 'ms'} ` +
|
|
360
|
+
`预检 ${m} ${probe.state}${probe.reason ? ` reason=${probe.reason}` : ''} ttft=${probe.ttftMs == null ? 'n/a' : Math.round(probe.ttftMs) + 'ms'} ` +
|
|
330
361
|
`prefill2000=${probe.prefillMs == null ? 'n/a' : Math.round(probe.prefillMs) + 'ms'} ` +
|
|
331
362
|
`tools=${toolText} endpoint=${probe.endpoint || '(无)'} ` +
|
|
332
363
|
`引擎自述上下文=${probe.engineCtx == null ? '未读到' : probe.engineCtx} 配置=${probe.configuredWindow ?? '?'}`
|
|
@@ -334,7 +365,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
334
365
|
} catch (/** @type {any} */ e) {
|
|
335
366
|
srvlog(`预检 ${m} 失败(忽略,按默认超时继续):${String(e?.message || e)}`);
|
|
336
367
|
}
|
|
337
|
-
probeByModel.set(m, probe);
|
|
368
|
+
probeByModel.set(m, { at: Date.now(), value: probe });
|
|
338
369
|
if (probeByModel.size > 50) probeByModel.delete(probeByModel.keys().next().value);
|
|
339
370
|
return probe;
|
|
340
371
|
}
|
|
@@ -532,6 +563,28 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
532
563
|
let lastProgressLogAt = 0;
|
|
533
564
|
let forecastLogged = false;
|
|
534
565
|
let ttftLogged = false;
|
|
566
|
+
// v0.6.13(探针误报修复):本回合实际用到的预检结论——首帧一到/回合结束就复核它(见下)。
|
|
567
|
+
let probeForTurn = /** @type {any} */ (null);
|
|
568
|
+
let probeRecheckLogged = false;
|
|
569
|
+
/**
|
|
570
|
+
* 预检判"不可达"、但本回合**真的出过首帧** → 写一行复核日志(只写一次)。
|
|
571
|
+
* 这是"探针误报"这件事唯一的自动化证据:真机 web-server.log:799 判不可达、:803 首帧 8.7s
|
|
572
|
+
* (事后只能靠人肉对时间线才能看出来,于是"预检判据有问题"这个结论一直没人能得出)。
|
|
573
|
+
* 判据取 `ttftMs != null`:**收到过首帧 = 端点确实可用**;回合失败/没出首帧时一个字都不写,
|
|
574
|
+
* 否则等于替一次真的"引擎没起来"洗白。
|
|
575
|
+
*/
|
|
576
|
+
const logProbeRecheck = () => {
|
|
577
|
+
if (probeRecheckLogged) return;
|
|
578
|
+
if (!probeForTurn || probeForTurn.state !== 'unreachable') return;
|
|
579
|
+
const tp = /** @type {any} */ (io?.turnProgress);
|
|
580
|
+
const ttft = tp?.ttftMs;
|
|
581
|
+
if (ttft == null) return;
|
|
582
|
+
probeRecheckLogged = true;
|
|
583
|
+
srvlog(
|
|
584
|
+
`chat 预检复核 ${taskId} 预检曾判不可达${probeForTurn.reason ? `(reason=${probeForTurn.reason})` : ''},` +
|
|
585
|
+
`但本回合实际可用(首帧 ${(ttft / 1000).toFixed(1)}s)——预检判据需复核`
|
|
586
|
+
);
|
|
587
|
+
};
|
|
535
588
|
const logTurnProgress = () => {
|
|
536
589
|
const tp = /** @type {any} */ (io?.turnProgress);
|
|
537
590
|
if (!tp) return;
|
|
@@ -542,6 +595,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
542
595
|
if (!ttftLogged && tp.ttftMs != null) {
|
|
543
596
|
ttftLogged = true;
|
|
544
597
|
srvlog(`chat 首帧 ${taskId} ${(tp.ttftMs / 1000).toFixed(1)}s(请求发出→第一帧;本地模型此项直接反映 prefill 快慢)`);
|
|
598
|
+
// v0.6.13(探针误报修复):预检判"不可达"但本回合**实际可用**——长回合在首帧时就先留一行
|
|
599
|
+
// (短回合不会走到这里的进度心跳,回合结束时还有一次复核,见 logProbeRecheck 的调用点)。
|
|
600
|
+
logProbeRecheck();
|
|
545
601
|
}
|
|
546
602
|
if (Date.now() - lastProgressLogAt < 30000) return;
|
|
547
603
|
lastProgressLogAt = Date.now();
|
|
@@ -563,6 +619,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
563
619
|
// v0.6.13(问题 3):等 `task` 子代理时把子代理自己的进度也写进日志——
|
|
564
620
|
// 真机现场只有"正在等待工具 task(已 620s)",看不出子代理是在干活还是卡死。
|
|
565
621
|
subagent: tp.subagent ?? null,
|
|
622
|
+
// v0.6.15(§3.59):本地端点因内存压力串行化时,进度行必须写"已串行化"
|
|
623
|
+
localQueue: tp.localQueue ?? null,
|
|
566
624
|
})
|
|
567
625
|
);
|
|
568
626
|
};
|
|
@@ -593,6 +651,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
593
651
|
? { label: tp.subagent.label ?? '', modelRounds: tp.subagent.modelRounds ?? 0, toolCalls: tp.subagent.toolCalls ?? 0, ms: tp.subagent.at ? Date.now() - tp.subagent.at : null }
|
|
594
652
|
: null,
|
|
595
653
|
stalled: Boolean(tp?.stalled),
|
|
654
|
+
// v0.6.15(§3.59):本地端点串行化状态——界面据此显示"已串行化(防内存压力)"
|
|
655
|
+
localQueue: tp?.localQueue ?? null,
|
|
596
656
|
});
|
|
597
657
|
} catch {}
|
|
598
658
|
try {
|
|
@@ -750,7 +810,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
750
810
|
}
|
|
751
811
|
// v0.6.12:**无论有没有预设,本回合生效的权限都以 turnPerm 为准**——显式选择在"没勾预设"这一格
|
|
752
812
|
// 同样必须生效(此前 createPermission 读的是 chatCfg.permission,即 config.json 的值)。
|
|
813
|
+
// v0.6.17 §3.64:来源也随 cfg 进账本(`run.start.permissionSource`)。用户那次"选自动却按只读跑"
|
|
814
|
+
// 之所以难自查,一部分原因正是账本只留下生效值、看不出这个值是显式选的还是预设/配置压的。
|
|
753
815
|
chatCfg = { ...chatCfg, permission: turnPerm.permission };
|
|
816
|
+
// 来源单独赋值:上面这一行是 smoke §137 的结构守卫锚点(chatCfg 的 permission 必须单源来自
|
|
817
|
+
// resolveTurnPermission),不要为了少一行把它并进对象字面量。
|
|
818
|
+
chatCfg.permissionSource = LEDGER_PERMISSION_SOURCE[turnPerm.source] ?? null;
|
|
754
819
|
// v0.6.12:**把"本会话的有效权限"说出来**。这次用户困惑的根源不是权限算错了,而是
|
|
755
820
|
// 「我选了自动、它按只读跑、而且没有任何一处告诉我」——预设是会话级粘滞的(下拉一直选着),
|
|
756
821
|
// 用户以为换个权限档就覆盖了它。新会话开始与档位变更各说一次,不刷屏。
|
|
@@ -836,9 +901,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
836
901
|
// v0.6.13(A 追加):**本地端点先预检、再开工**——"这个端点能不能跑这种任务"必须几秒内有答案。
|
|
837
902
|
// 预检结果:① 打到界面上(banner,发送前可见);② 落 web-server.log;③ 随 chatCfg 传给 agent,
|
|
838
903
|
// 让"发送前规模预告"把实测 TTFT 与本任务上下文一起说出来;④ 没配超时时按它自适应看门狗阈值。
|
|
904
|
+
//
|
|
905
|
+
// v0.6.13(探针误报修复):整段**尽力而为、失败只降级不阻断**——外层 try/catch 兜住一切异常,
|
|
906
|
+
// 预检判"不可达/超时"也照常开工(只影响 banner 文案与自适应超时取值);
|
|
907
|
+
// 探针自身的耗时有硬上限(单次 1.5s × 3 + 退避,总预算 15s,见 probeEndpoint)。
|
|
839
908
|
try {
|
|
840
909
|
const probe = await ensureProbe(runModel);
|
|
841
910
|
if (probe) {
|
|
911
|
+
probeForTurn = probe;
|
|
842
912
|
const adapt = adaptiveTimeouts({
|
|
843
913
|
probeTtftMs: probe.ttftMs,
|
|
844
914
|
probePrefillMs: probe.prefillMs,
|
|
@@ -880,10 +950,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
880
950
|
});
|
|
881
951
|
chatCfg = { ...chatCfg, endpointProbe: probe };
|
|
882
952
|
if (!(Number(cfg.noProgressTimeoutMs) > 0)) chatCfg.noProgressTimeoutMs = adapt.noProgressTimeoutMs;
|
|
883
|
-
srvlog(`chat 预检 ${taskId} ${probe.state} ${probe.note}`);
|
|
953
|
+
srvlog(`chat 预检 ${taskId} ${probe.state}${probe.reason ? ` reason=${probe.reason}` : ''} ${probe.note}`);
|
|
884
954
|
}
|
|
885
955
|
} catch (/** @type {any} */ e) {
|
|
886
|
-
|
|
956
|
+
// 预检失败**不得**影响本回合发起("尽力而为:失败只降级不阻断")——这里只留一行日志。
|
|
957
|
+
srvlog(`chat 预检失败(忽略,按既有默认继续) ${taskId} ${String(e?.message || e)}`);
|
|
887
958
|
}
|
|
888
959
|
} catch (/** @type {any} */ err) { entry.status = 'failed';
|
|
889
960
|
notifyBusy();
|
|
@@ -951,6 +1022,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
951
1022
|
try {
|
|
952
1023
|
const r = await agent.runTurn(messages);
|
|
953
1024
|
srvlog('chat 回合完成 ' + taskId + ' text=' + String(r.text || '').length + ' ' + (Date.now() - entry.startedAt) + 'ms');
|
|
1025
|
+
// v0.6.13(探针误报修复):**回合结束后**的复核行——"预检判不可达、但本回合实际可用"必须留痕。
|
|
1026
|
+
// 真机现场(web-server.log:799 判不可达 → :803 首帧 8.7s → :844 已跑 21m)此前只能靠人肉对时间线
|
|
1027
|
+
// 才能看出,于是"探针判据有问题"这个结论一直没人能得出(用户反而去重启了引擎)。
|
|
1028
|
+
// 判据用 `ttftMs != null`:真的收到过首帧 = 端点确实可用(回合失败/没出首帧时不写,避免误导)。
|
|
1029
|
+
logProbeRecheck();
|
|
954
1030
|
// 会话级路由统计(Hermes C2 升级检测):累计工具步数与截断次数,粘滞 flash 会话复杂度上来后自动升 planner
|
|
955
1031
|
const st = session.routeStats || { steps: 0, truncated: 0 };
|
|
956
1032
|
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "readonly-audit",
|
|
3
|
-
"label": "只读代码审计",
|
|
4
|
-
"description": "只读代码审计预设:审计人格 + 只读工具白名单 + 保守参数。三点先说清:① 本预设**不启用审计日志/账本**——那是 config.audit 与 `mingdao ledger`,与本预设无关,别把它当合规审计开关;② 本预设**不覆盖你的权限档**——只读效果由 tools 白名单保证(里面没有 write/edit,模型根本看不到写工具),权限档始终以你的显式选择为准(recommendedPermission=readonly 只是建议,不参与判定);③ 它**不是**本地模型专用预设——要调本地模型的上下文/输出预算用 local-model(本预设与它相互独立)。",
|
|
5
|
-
"systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。对于大仓库可分模块派发只读子代理并行审查(task 工具传 readOnly:true,子代理同样只读:read/ls/glob/grep/skill)。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
|
|
6
|
-
"tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo", "task"],
|
|
7
|
-
"recommendedPermission": "readonly",
|
|
8
|
-
"maxRounds": 4,
|
|
9
|
-
"maxOutputTokens": 4096
|
|
10
|
-
}
|