mingdao-harness 0.3.0 → 0.3.2
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/docs/CONFIG.md +24 -2
- package/docs/PLAN-v0.3.0.md +1 -1
- package/docs/PLAN-v0.3.2.md +65 -0
- package/package.json +1 -1
- package/src/agent.js +68 -12
- package/src/audit.js +3 -4
- package/src/cli.js +3 -3
- package/src/commands/diagnose.js +2 -12
- package/src/commands/repl.js +3 -3
- package/src/compact.js +3 -1
- package/src/memory.js +35 -1
- package/src/model-caps.js +69 -0
- package/src/providers/index.js +45 -6
- package/src/providers/openai-compatible.js +8 -3
- package/src/redact.js +26 -0
- package/src/task-state.js +18 -0
- package/src/tools/fetch.js +56 -0
- package/src/tools/git.js +31 -0
- package/src/tools/index.js +39 -1
- package/src/web/app.js +63 -64
- package/src/web/index.html +72 -22
- package/src/web/routes/domains/config.js +26 -2
- package/src/web/server.js +11 -5
package/docs/CONFIG.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
| `baseUrl` | OpenAI 兼容 API 地址(可覆盖内置服务商默认值) |
|
|
24
24
|
| `permission` | `ask`(默认)/ `auto` / `readonly`,或规则对象(见下) |
|
|
25
25
|
| `sandbox` | `off` / `readonly` / `safe`(Linux + bubblewrap;其余平台自动降级) |
|
|
26
|
-
| `contextBudget` |
|
|
26
|
+
| `contextBudget` | 期望的上下文预算 tokens;实际预算按模型窗口自动收紧(见「本地模型自适应」) |
|
|
27
27
|
|
|
28
28
|
可选字段:`temperature`、`maxOutputTokens`、`includeUsage`(流式请求 usage 统计,个别网关不支持
|
|
29
29
|
`stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
|
|
@@ -221,7 +221,8 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
221
221
|
{
|
|
222
222
|
"customModels": {
|
|
223
223
|
"my-gpt4": { "label": "我的 GPT-4 网关", "baseUrl": "https://gateway.example.com/v1" },
|
|
224
|
-
"my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" }
|
|
224
|
+
"my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" },
|
|
225
|
+
"local-qwen": { "label": "本机 Qwen", "baseUrl": "http://127.0.0.1:8081/v1", "contextWindow": 131072, "maxOutputTokens": 8192 }
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
```
|
|
@@ -231,6 +232,27 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
231
232
|
自定义端点若跑的是 DeepSeek 系模型(模型名不以 `deepseek` 开头时默认走启发式估算、预算误差
|
|
232
233
|
可达 ±2 倍),加 `"tokenizer": "deepseek"` 即按官方词表精确计数:
|
|
233
234
|
|
|
235
|
+
### 本地模型自适应(v0.3.2)
|
|
236
|
+
|
|
237
|
+
本机/内网部署的推理框架(baseUrl 为 `127.0.0.1`/`localhost`/私网 IP)自动按「资源有限」对待,
|
|
238
|
+
避免长任务把上下文撑到窗口边缘后 prefill 指数恶化、被客户端超时掐断(典型:127k 上下文首 token
|
|
239
|
+
需 200s+,客户端 3 分钟无响应断开 → network error)。机制:
|
|
240
|
+
|
|
241
|
+
- **上下文窗口感知**:`customModels.<name>.contextWindow` 显式声明模型真实窗口;未声明时本地
|
|
242
|
+
模型兜底 **32k**、远程兜底 **128k**。
|
|
243
|
+
- **安全预算**:`contextBudget` 会被自动收紧到 `min(contextBudget, 窗口×75%, 窗口−maxOutput−余量)`,
|
|
244
|
+
prompt 永不逼近窗口边缘(75% 舒适区以上 prefill 时间陡增)。
|
|
245
|
+
- **边缘检测**:模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮强制压缩历史(即使启发式
|
|
246
|
+
计数低估也强制触发)。
|
|
247
|
+
- **工具输出截断**:单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
|
|
248
|
+
- **分层超时**:`timeout.firstTokenMs`(首 token 等待,本地默认 600s / 远程 300s)、
|
|
249
|
+
`timeout.streamIdleMs`(流式空闲,默认 120s)、`timeout.totalMs`(总量,本地 30min / 远程 10min),
|
|
250
|
+
留空则自适应;本地慢 prefill 不再被一刀切超时误杀。
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{ "timeout": { "firstTokenMs": 600000, "streamIdleMs": 120000, "totalMs": 1800000 } }
|
|
254
|
+
```
|
|
255
|
+
|
|
234
256
|
## 自定义 Provider 模块(非 OpenAI 兼容协议)
|
|
235
257
|
|
|
236
258
|
在 `~/.mingdao/providers/<name>.mjs` 导出:
|
package/docs/PLAN-v0.3.0.md
CHANGED
|
@@ -70,7 +70,7 @@ v0.3.0 落地的三条主线共享同一个地基——**「记忆/上下文」*
|
|
|
70
70
|
|
|
71
71
|
## 三、v0.3.0 之外(顺延序列,防漂移备忘)
|
|
72
72
|
|
|
73
|
-
- **v0.3.1
|
|
73
|
+
- **v0.3.1「检索与武器」**:零依赖语义检索(项目记忆从"全量→相关条目"注入,`retrieveRelevant` 分词 Jaccard);`git` 只读分析(diff/log/status 等,无 shell 防注入)与 HTTP 只读抓取(SSRF 白名单兜底)工具;省钱仪表盘从设置移出、顶部费用徽标点击展开(KPI + 命中率环状仪表 + 14 天趋势面积图 + 模型/工具 Top 条形 + 最近缓存)。**完整基线+增量上下文(P0-4)顺延 v0.3.2**(风险高,语义检索 + 续跑进度摘要已覆盖主场景)。
|
|
74
74
|
- **v0.3.2「看得见的省钱 + 省心」**:省钱归因面板(单任务省多少/花在哪一步/缓存命中贡献,WebUI 内 SVG 折线已有基础);`diagnose` 的自动反馈模板;覆盖率阈值随版本上调。
|
|
75
75
|
- **v0.3.3「纵深能力」**:跨平台沙箱补位(Windows Job Object / macOS seatbelt,平台无关敏感命令兜底);WebUI「完成任务的舒服工作台」深化(过程可回放/结论可复现)。
|
|
76
76
|
- **明确不进任何近期版本**:全模型省钱平台、React/框架化重构、SQLite(Node ≥22.5 破坏 18/20)、SaaS 托管化。
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# v0.3.2 规划:本地模型自适应(资源受限部署不中断)
|
|
2
|
+
|
|
3
|
+
## 背景(用户 MacBook M5 Pro 实测定位结论)
|
|
4
|
+
|
|
5
|
+
本地部署 `mtplx-qwen38-27b`(131072 窗口 + q8 KV 量化)跑 71 步长任务时 network error。
|
|
6
|
+
其他 agent 定位结论:**不是内存拒绝,是「长上下文 prefill 过慢 → 客户端等待超时主动断连」**。
|
|
7
|
+
|
|
8
|
+
- 服务端无任何 507/内存拒绝,q8 生效,峰值 47.38GB < 48G 预算。
|
|
9
|
+
- 失败请求:prompt=127,912 / 131,072(窗口边缘),`request_cancelled=client_disconnected`,
|
|
10
|
+
188.9s 内 0 token 输出。
|
|
11
|
+
- prefill 指数恶化:ttft 46.4s → 67.8s → 96.0s → 196.5s → 中断(189s 无输出)。
|
|
12
|
+
根因:每轮全量历史 + 上一轮大段代码输出回灌,新增 prefill 从 7.9k 涨到 32k,
|
|
13
|
+
prefill 速度仅 ~165–185 tok/s,ttft 超过客户端等待阈值。
|
|
14
|
+
|
|
15
|
+
调用 DeepSeek 官方 API 无此问题(窗口 1M + prefill 极快),说明瓶颈在「资源受限的本地模型」,
|
|
16
|
+
必须做成**共性能力**:其他客户本地部署更小模型(窗口小/内存少)也会踩,不能只修 MacBook。
|
|
17
|
+
|
|
18
|
+
## 目标
|
|
19
|
+
|
|
20
|
+
让 MingDao 针对不同「参数 / 上下文窗口 / KV cache / 机器资源」的模型**灵活自适应**,
|
|
21
|
+
小模型、低内存自动收紧参数适应,不撑爆、不误杀;本地慢 prefill 不被一刀切超时掐断。
|
|
22
|
+
|
|
23
|
+
## 方案(本次实现)
|
|
24
|
+
|
|
25
|
+
### 1. 模型能力解析(`src/model-caps.js` 新增)
|
|
26
|
+
单一来源解析 `contextWindow / maxOutputTokens / isLocal`:
|
|
27
|
+
- 优先级:`customModels.<name>.contextWindow/maxOutputTokens` > 内置 preset > 兜底。
|
|
28
|
+
- 兜底:本地模型 32k、远程 128k(本地小模型宁可保守不撑爆)。
|
|
29
|
+
- `isLocalBaseUrl`:127.0.0.1 / localhost / 私网 IP 判定本地推理框架。
|
|
30
|
+
|
|
31
|
+
### 2. 安全预算推导(`safeBudget`)
|
|
32
|
+
`budget = min(期望 contextBudget, 窗口×75% 舒适区, 窗口 − maxOutput − 2048 余量)`。
|
|
33
|
+
prompt 永不逼近窗口边缘(75% 以上 prefill 时间陡增),从根上避免 prefill 爆炸。
|
|
34
|
+
对内置模型零影响(pro 200k / flash 128k 均远小于各自窗口 75%)。
|
|
35
|
+
|
|
36
|
+
### 3. 分层超时(providers)
|
|
37
|
+
- 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill)。
|
|
38
|
+
- 流式空闲:有帧后 120s 无新帧即断(真挂死才断)。
|
|
39
|
+
- 总量:本地 30min / 远程 10min。
|
|
40
|
+
- `config.timeout.{firstTokenMs,streamIdleMs,totalMs}` 可覆盖;`parseStream` 按「帧到达」刷新
|
|
41
|
+
空闲计时(prefill 阶段服务端可能先发 usage-only 帧,不误杀)。
|
|
42
|
+
|
|
43
|
+
### 4. 边缘检测 + 强制压缩
|
|
44
|
+
模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮 `force` 压缩——即便非 DeepSeek 模型
|
|
45
|
+
启发式计数低估(误差 ±2 倍)也强制触发(`compact.js` force 绕过触发线门槛)。
|
|
46
|
+
|
|
47
|
+
### 5. 工具输出截断自适应
|
|
48
|
+
单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
|
|
49
|
+
|
|
50
|
+
### 6. 配置/UI 打通
|
|
51
|
+
- `customModels.<name>.contextWindow/maxOutputTokens`(WebUI 添加自定义模型表单新增两项)。
|
|
52
|
+
- `config.timeout.*`(设置 → 通用面板新增三项,秒为单位,留空自适应)。
|
|
53
|
+
- `/api/config`、`/api/models-config` 契约扩展。
|
|
54
|
+
|
|
55
|
+
## 验收
|
|
56
|
+
|
|
57
|
+
- smoke 新增 model-caps(本地/远程判定、窗口兜底/显式声明、舒适区+输出余量预算)与
|
|
58
|
+
provider 分层超时(首 token vs 流式空闲)两组断言。
|
|
59
|
+
- 全绿:smoke 69 组 / e2e-local / e2e-web / e2e-schedule / api-contracts / bench;strict 0/0。
|
|
60
|
+
- 用户在 3820 用本地模型跑长任务验收,确认无 network error 中断后发布。
|
|
61
|
+
|
|
62
|
+
## 非目标(顺延)
|
|
63
|
+
|
|
64
|
+
- 增量上下文(基线+变化)——自动续跑 + 语义检索已覆盖省钱与长程主场景。
|
|
65
|
+
- 本地窗口自动探测(从服务端 /models 读 max_model_len)——依赖各家推理框架能力,暂用显式声明。
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -6,17 +6,22 @@ import { trimMessages, clampText, messageTokens, approxTokens } from './context.
|
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
7
|
import { buildToolSchemas, dispatch } from './tools/index.js';
|
|
8
8
|
import { modelPreset } from './models.js';
|
|
9
|
+
import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
|
|
9
10
|
import { makeTokenCounter } from './tokenizer.js';
|
|
10
11
|
import { createHooks } from './hooks.js';
|
|
11
12
|
import { createIO, style, C } from './ui.js';
|
|
12
13
|
import { subagentModel } from './routing.js';
|
|
13
|
-
import { writeAudit
|
|
14
|
+
import { writeAudit } from './audit.js';
|
|
15
|
+
import { redactSecrets } from './redact.js';
|
|
14
16
|
import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
|
|
15
17
|
import { estimateCost } from './pricing.js';
|
|
16
18
|
import { resolveProviderConfig } from './providers/index.js';
|
|
17
19
|
|
|
18
20
|
const MAX_STEPS = 24;
|
|
19
|
-
|
|
21
|
+
// 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
|
|
22
|
+
// (v0.3.0 桌面版审计实测:多个只读子代理报「因达到步数上限停止读取」或「子任务无输出」)。
|
|
23
|
+
// 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
|
|
24
|
+
const SUBAGENT_MAX_STEPS = 24;
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
@@ -25,8 +30,19 @@ const SUBAGENT_MAX_STEPS = 12;
|
|
|
25
30
|
*/
|
|
26
31
|
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
|
|
27
32
|
const preset = modelPreset(modelName) || {};
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
// v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
|
|
34
|
+
// 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
|
|
35
|
+
const caps = resolveModelCaps(cfg, modelName);
|
|
36
|
+
// safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
|
|
37
|
+
// 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
|
|
38
|
+
const budget = safeBudget(cfg, caps);
|
|
39
|
+
// maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
|
|
40
|
+
// (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
|
|
41
|
+
const maxOutput = Math.min(cfg.maxOutputTokens || caps.maxOutputTokens, Math.max(1024, caps.contextWindow - budget));
|
|
42
|
+
// v0.3.2 工具输出截断自适应:窗口越小截得越狠(单条工具结果按窗口 1/16 封顶,最少 2000 字),
|
|
43
|
+
// 但绝不超过旧默认 20000(大窗口模型如 1M 不因公式放大回灌、不推高成本)。
|
|
44
|
+
// 本地小模型(32k 窗口 → 2k 字)不再把大段代码/日志整条回灌,省 prompt 且不撑爆窗口。
|
|
45
|
+
const toolResultCap = Math.min(20000, Math.max(2000, Math.floor(caps.contextWindow / 16)));
|
|
30
46
|
const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
|
|
31
47
|
const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
|
|
32
48
|
const hooks = createHooks(cfg.hooks, workingDir);
|
|
@@ -34,8 +50,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
34
50
|
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
35
51
|
const undo = undoStore || { backups: new Map() };
|
|
36
52
|
const stepLimit = maxSteps || MAX_STEPS;
|
|
37
|
-
// 只读工具集合(子代理只读模式 +
|
|
38
|
-
const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill']);
|
|
53
|
+
// 只读工具集合(子代理只读模式 + 并行批次共用)。v0.3.1 起含 git/fetch(只读、审计常用)
|
|
54
|
+
const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'git', 'fetch']);
|
|
39
55
|
// 精确 token 计数:DeepSeek 词表,其他模型回退启发式
|
|
40
56
|
const count = makeTokenCounter(modelName);
|
|
41
57
|
// MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
|
|
@@ -45,7 +61,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
45
61
|
const usedToolNames = new Set();
|
|
46
62
|
// 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
|
|
47
63
|
// + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
|
|
48
|
-
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
|
|
64
|
+
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch']);
|
|
49
65
|
// 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
|
|
50
66
|
const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
|
|
51
67
|
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
@@ -121,6 +137,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
121
137
|
async function runTurn(/** @type {any} */ messages) {
|
|
122
138
|
let steps = 0;
|
|
123
139
|
let finish = null;
|
|
140
|
+
// v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
|
|
141
|
+
// 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
|
|
142
|
+
const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
|
|
143
|
+
let round = 0;
|
|
124
144
|
const usage = /** @type {{ prompt_tokens: number, completion_tokens: number, prompt_cache_hit_tokens?: number, prompt_cache_miss_tokens?: number }} */ ({ prompt_tokens: 0, completion_tokens: 0 });
|
|
125
145
|
const startedAt = Date.now();
|
|
126
146
|
// 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
|
|
@@ -130,6 +150,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
130
150
|
// 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
|
|
131
151
|
let reasoningTokens = 0;
|
|
132
152
|
const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
|
|
153
|
+
const deliverables = /** @type {string[]} */ ([]); // 本回合 write/edit 成功落盘的文件路径(去重)
|
|
133
154
|
const perf = () => ({
|
|
134
155
|
llmMs: llmMsTotal,
|
|
135
156
|
toolMs: toolMsTotal,
|
|
@@ -138,10 +159,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
138
159
|
reasoningTokens,
|
|
139
160
|
toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
|
|
140
161
|
usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
|
|
162
|
+
deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
|
|
141
163
|
});
|
|
142
164
|
let aborted = false;
|
|
143
165
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
144
166
|
let currentAc = /** @type {any} */ (null);
|
|
167
|
+
// v0.3.2 边缘检测状态:模型上报 prompt_tokens 逼近窗口 → 下一轮强制压缩(见下方 compactConversation force)
|
|
168
|
+
let windowPressure = false;
|
|
169
|
+
let pressureWarned = false;
|
|
145
170
|
// 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
|
|
146
171
|
// activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
|
|
147
172
|
let activeModel = modelName;
|
|
@@ -174,7 +199,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
174
199
|
}
|
|
175
200
|
};
|
|
176
201
|
try {
|
|
177
|
-
|
|
202
|
+
for (round = 0; round < maxRounds; round++) {
|
|
203
|
+
steps = 0;
|
|
204
|
+
while (steps < stepLimit) {
|
|
178
205
|
steps += 1;
|
|
179
206
|
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
|
|
180
207
|
const turnToolCache = new Map();
|
|
@@ -189,6 +216,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
189
216
|
provider,
|
|
190
217
|
executorModel: subagentModel(cfg, modelName),
|
|
191
218
|
triggerRatio: cfg.compactTrigger, // 可配置触发线(默认 80%)
|
|
219
|
+
force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
|
|
192
220
|
});
|
|
193
221
|
if (compacted) {
|
|
194
222
|
messages.splice(0, messages.length, ...compacted.messages);
|
|
@@ -356,6 +384,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
356
384
|
if (Number.isFinite(res.usage.prompt_cache_miss_tokens)) {
|
|
357
385
|
usage.prompt_cache_miss_tokens = (usage.prompt_cache_miss_tokens || 0) + res.usage.prompt_cache_miss_tokens;
|
|
358
386
|
}
|
|
387
|
+
// v0.3.2 边缘检测:模型上报的真实 prompt_tokens(含缓存命中)逼近窗口 85% 即标记——
|
|
388
|
+
// 下一轮强制激进压缩(force),不让 prompt 逼近窗口边缘导致 prefill 指数恶化。
|
|
389
|
+
const realPrompt = Number(res.usage.prompt_tokens);
|
|
390
|
+
if (Number.isFinite(realPrompt) && realPrompt > 0 && realPrompt >= caps.contextWindow * EDGE_RATIO) {
|
|
391
|
+
windowPressure = true;
|
|
392
|
+
if (!pressureWarned) {
|
|
393
|
+
pressureWarned = true;
|
|
394
|
+
io.print(style(`⚠ 上下文已逼近模型窗口(${realPrompt}/${caps.contextWindow},≥${Math.round(EDGE_RATIO * 100)}%),下轮将强制压缩历史防 prefill 恶化`, C.yellow));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
359
397
|
}
|
|
360
398
|
|
|
361
399
|
if (res.toolCalls?.length) {
|
|
@@ -484,6 +522,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
484
522
|
ts.ms += ms;
|
|
485
523
|
toolStats.set(prep.name, ts);
|
|
486
524
|
io.renderTool(prep.name, prep.args, result, ms);
|
|
525
|
+
// v0.3.1 P1-2 修复:write/edit 成功落盘的路径记入交付物(CLI/REPL 续跑检查点用)
|
|
526
|
+
if ((prep.name === 'write' || prep.name === 'edit') && prep.args?.path && result && result.ok !== false) {
|
|
527
|
+
const p = String(prep.args.path);
|
|
528
|
+
if (p && !deliverables.includes(p)) deliverables.push(p);
|
|
529
|
+
}
|
|
487
530
|
if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
|
|
488
531
|
hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
|
|
489
532
|
// 审计(P3-5):执行结果摘要(含退出码/超时/输出大小)
|
|
@@ -512,7 +555,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
512
555
|
}
|
|
513
556
|
const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
514
557
|
const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
|
|
515
|
-
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
|
|
558
|
+
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text, toolResultCap) });
|
|
516
559
|
}
|
|
517
560
|
|
|
518
561
|
let i = 0;
|
|
@@ -563,9 +606,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
563
606
|
}
|
|
564
607
|
}
|
|
565
608
|
}
|
|
566
|
-
// v0.2.8 步数预留收尾(对齐 DSH
|
|
567
|
-
//
|
|
568
|
-
if (steps === stepLimit - 1) {
|
|
609
|
+
// v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
|
|
610
|
+
// 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
|
|
611
|
+
if (steps === stepLimit - 1 && round === maxRounds - 1) {
|
|
569
612
|
messages.push({
|
|
570
613
|
role: 'user',
|
|
571
614
|
content:
|
|
@@ -642,6 +685,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
642
685
|
};
|
|
643
686
|
}
|
|
644
687
|
}
|
|
688
|
+
// v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
|
|
689
|
+
if (round < maxRounds - 1 && !aborted) {
|
|
690
|
+
const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
|
|
691
|
+
messages.push({
|
|
692
|
+
role: 'user',
|
|
693
|
+
content: `(系统提示)已连续执行 ${stepLimit} 步工具操作,任务尚未完成,请继续完成剩余工作。${art}先核对已完成部分(勿重复),再做未完成的部分。`,
|
|
694
|
+
});
|
|
695
|
+
io.print(style(`♻ 步数上限,自动续跑第 ${round + 2} 轮…`, C.dim));
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
645
698
|
io.endTurn();
|
|
646
699
|
// 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
|
|
647
700
|
stripOrphanCalls();
|
|
@@ -679,6 +732,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
679
732
|
} catch {}
|
|
680
733
|
}
|
|
681
734
|
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
735
|
+
}
|
|
736
|
+
// 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
|
|
737
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
682
738
|
} finally {
|
|
683
739
|
currentAc = null;
|
|
684
740
|
offSigint();
|
package/src/audit.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
|
+
import { redactSecrets } from './redact.js';
|
|
10
11
|
|
|
11
12
|
const MAX_LINES = 20000;
|
|
12
13
|
const KEEP_LINES = 10000;
|
|
@@ -16,10 +17,8 @@ export function auditFile() {
|
|
|
16
17
|
return path.join(mingdaoHome(), 'audit.jsonl');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
export
|
|
21
|
-
return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
|
|
22
|
-
}
|
|
20
|
+
// 轻量脱敏(统一单一来源 v0.3.1 P1-1):sk-/ghp_ 等常见前缀掩码,见 src/redact.js
|
|
21
|
+
export { redactSecrets };
|
|
23
22
|
|
|
24
23
|
export function writeAudit(/** @type {any} */ entry) {
|
|
25
24
|
try {
|
package/src/cli.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
formatScheduleRow,
|
|
35
35
|
} from './schedule.js';
|
|
36
36
|
import { createAgent } from './agent.js';
|
|
37
|
-
import {
|
|
37
|
+
import { saveTaskStateMerge, clearTaskState } from './task-state.js';
|
|
38
38
|
import { createPermission } from './permissions.js';
|
|
39
39
|
import { buildSystemPrompt } from './prompts.js';
|
|
40
40
|
import { listSkills, tamperedSkillNames } from './skills.js';
|
|
@@ -474,10 +474,10 @@ async function main() {
|
|
|
474
474
|
}
|
|
475
475
|
// v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
|
|
476
476
|
if (res.capHit || res.aborted) {
|
|
477
|
-
|
|
477
|
+
saveTaskStateMerge(path.basename(session.file), {
|
|
478
478
|
goal: question,
|
|
479
479
|
progress: res.text || '',
|
|
480
|
-
artifacts: [],
|
|
480
|
+
artifacts: res.perf?.deliverables || [],
|
|
481
481
|
status: res.capHit ? 'cap' : 'interrupted',
|
|
482
482
|
updatedAt: new Date().toISOString(),
|
|
483
483
|
});
|
package/src/commands/diagnose.js
CHANGED
|
@@ -7,22 +7,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { createIO, style, C } from '../ui.js';
|
|
8
8
|
import { mingdaoHome, ensureHome, loadConfig } from '../config.js';
|
|
9
9
|
import { credentialsPath } from '../credentials.js';
|
|
10
|
-
import { listAudit
|
|
10
|
+
import { listAudit } from '../audit.js';
|
|
11
|
+
import { redactSensitive } from '../redact.js';
|
|
11
12
|
import { projectMemoryFile, loadProjectMemory } from '../memory.js';
|
|
12
13
|
import { listWorkspaces } from '../workspace.js';
|
|
13
14
|
import { detectSandbox } from '../tools/bash.js';
|
|
14
15
|
|
|
15
|
-
// 更严格的脱敏:sk-/ghp_ token、key/token/secret/password=值、私网 IP、家目录路径
|
|
16
|
-
function redactSensitive(/** @type {any} */ text) {
|
|
17
|
-
let s = redactSecrets(text);
|
|
18
|
-
s = s.replace(/(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, 'ghp_***');
|
|
19
|
-
s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
|
|
20
|
-
s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
|
|
21
|
-
const home = os.homedir();
|
|
22
|
-
if (home && home.length > 1) s = s.split(home).join('~');
|
|
23
|
-
return s;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
function tailFile(/** @type {any} */ file, /** @type {number} */ lines = 60) {
|
|
27
17
|
try {
|
|
28
18
|
const raw = fs.readFileSync(file, 'utf8');
|
package/src/commands/repl.js
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
relativeTime,
|
|
55
55
|
searchSessions,
|
|
56
56
|
} from '../session.js';
|
|
57
|
-
import { loadTaskState,
|
|
57
|
+
import { loadTaskState, saveTaskStateMerge, clearTaskState, resumePrompt } from '../task-state.js';
|
|
58
58
|
|
|
59
59
|
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
60
60
|
|
|
@@ -682,10 +682,10 @@ export async function runRepl(ctx) {
|
|
|
682
682
|
}
|
|
683
683
|
// v0.3.0 P0-2:跑满步数(capHit)或中断(aborted)落检查点,正常完成清除
|
|
684
684
|
if (res.capHit || res.aborted) {
|
|
685
|
-
|
|
685
|
+
saveTaskStateMerge(path.basename(session.file), {
|
|
686
686
|
goal: input,
|
|
687
687
|
progress: res.text || '',
|
|
688
|
-
artifacts: [],
|
|
688
|
+
artifacts: res.perf?.deliverables || [],
|
|
689
689
|
status: res.capHit ? 'cap' : 'interrupted',
|
|
690
690
|
updatedAt: new Date().toISOString(),
|
|
691
691
|
});
|
package/src/compact.js
CHANGED
|
@@ -65,7 +65,9 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
65
65
|
total += t;
|
|
66
66
|
}
|
|
67
67
|
const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
|
|
68
|
-
|
|
68
|
+
// force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
|
|
69
|
+
// 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
|
|
70
|
+
if (total <= budget * trigger && !force) return null;
|
|
69
71
|
// 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
|
|
70
72
|
let keepTokens = sizes[0] ?? 0;
|
|
71
73
|
let boundary = messages.length;
|
package/src/memory.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
10
|
import { beijingParts } from './pricing.js';
|
|
11
|
+
import { tokenize } from './session-index.js';
|
|
11
12
|
|
|
12
13
|
export function memoryFile() {
|
|
13
14
|
return path.join(mingdaoHome(), 'AGENTS.md');
|
|
@@ -231,6 +232,32 @@ export function loadProjectMemory(/** @type {any} */ workingDir) {
|
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
// 项目记忆按条目读取(供语义检索)
|
|
236
|
+
export function loadProjectMemoryEntries(/** @type {any} */ workingDir) {
|
|
237
|
+
return loadProjectMemory(workingDir)
|
|
238
|
+
.split('\n')
|
|
239
|
+
.map((/** @type {any} */ l) => l.trim())
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 零依赖语义检索:与 query 分词(ASCII 词 + 中文 bigram)的 Jaccard 相似度取 TopN 相关条目。
|
|
244
|
+
// 用于「记忆/日志从最近 N 条 → 相关 N 条」(v0.3.1),换会话/换任务只注入相关记忆、省 token。
|
|
245
|
+
export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} */ query, /** @type {number} */ topN = 5) {
|
|
246
|
+
const qTerms = new Set([...tokenize(String(query || '')).keys()]);
|
|
247
|
+
if (!qTerms.size) return entries.slice(0, topN);
|
|
248
|
+
const scored = entries.map((/** @type {any} */ e) => {
|
|
249
|
+
const text = typeof e === 'string' ? e : String(e.text || e.outcome || e.firstUser || e.content || '');
|
|
250
|
+
const terms = tokenize(text);
|
|
251
|
+
if (!terms.size) return { e, score: 0 };
|
|
252
|
+
let overlap = 0;
|
|
253
|
+
for (const t of terms.keys()) if (qTerms.has(t)) overlap += 1;
|
|
254
|
+
const union = new Set([...qTerms, ...terms.keys()]).size || 1;
|
|
255
|
+
return { e, score: overlap / union };
|
|
256
|
+
});
|
|
257
|
+
scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
|
|
258
|
+
return scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
|
|
259
|
+
}
|
|
260
|
+
|
|
234
261
|
export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
|
|
235
262
|
const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
|
|
236
263
|
if (!add.length || !workingDir) return 0;
|
|
@@ -340,7 +367,14 @@ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, pr
|
|
|
340
367
|
export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
|
|
341
368
|
const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
|
|
342
369
|
try {
|
|
343
|
-
|
|
370
|
+
// v0.3.1 P1-3 修复:journal 归属信息——优先工作空间名,回退目录 basename(不再恒 null)
|
|
371
|
+
let wsName = null;
|
|
372
|
+
try {
|
|
373
|
+
const { workspaceForDir } = await import('./workspace.js');
|
|
374
|
+
wsName = workspaceForDir(workingDir)?.name || (workingDir ? path.basename(workingDir) : null);
|
|
375
|
+
} catch {
|
|
376
|
+
wsName = workingDir ? path.basename(workingDir) : null;
|
|
377
|
+
}
|
|
344
378
|
appendJournal(home, {
|
|
345
379
|
at: Date.now(),
|
|
346
380
|
workspace: wsName,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 模型能力解析(v0.3.2 本地模型自适应):
|
|
2
|
+
// 把「模型能装多少上下文、单次最多输出多少、是否本地部署」收敛成单一来源,
|
|
3
|
+
// 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
|
|
4
|
+
// 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
|
|
5
|
+
import { modelPreset } from './models.js';
|
|
6
|
+
|
|
7
|
+
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
8
|
+
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
9
|
+
export const UNKNOWN_REMOTE_WINDOW = 128000;
|
|
10
|
+
export const DEFAULT_MAX_OUTPUT = 8192;
|
|
11
|
+
// 输出余量:prompt 预算必须给模型输出留足空间,否则 prompt+output 越过窗口 → 服务端截断/拒绝。
|
|
12
|
+
export const OUTPUT_HEADROOM = 2048;
|
|
13
|
+
// 舒适区:prompt 预算最多占窗口 75%——逼近 75% 以上时 prefill 时间陡增(长上下文 dequant 开销),
|
|
14
|
+
// 留 25% 给输出 + 抗抖缓冲,从根上避免「prompt 到窗口边缘 → 首 token 等 200s+ 被客户端掐断」。
|
|
15
|
+
export const COMFORT_RATIO = 0.75;
|
|
16
|
+
// 边缘比:模型上报的真实 prompt_tokens 逼近窗口 85% 即视为「边缘」,本回合结束强制激进压缩。
|
|
17
|
+
export const EDGE_RATIO = 0.85;
|
|
18
|
+
|
|
19
|
+
/** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
|
|
20
|
+
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(String(baseUrl || ''));
|
|
23
|
+
const h = u.hostname.toLowerCase();
|
|
24
|
+
if (!h) return false;
|
|
25
|
+
if (h === 'localhost' || h === '::1') return true;
|
|
26
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
27
|
+
if (!m) return false;
|
|
28
|
+
const a = Number(m[1]);
|
|
29
|
+
const b = Number(m[2]);
|
|
30
|
+
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
|
|
38
|
+
* @param {any} cfg
|
|
39
|
+
* @param {string} modelName
|
|
40
|
+
* @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
43
|
+
const preset = modelPreset(modelName);
|
|
44
|
+
const cm = (cfg?.customModels || {})[modelName] || {};
|
|
45
|
+
const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
|
|
46
|
+
const isLocal = isLocalBaseUrl(baseUrl);
|
|
47
|
+
const contextWindow =
|
|
48
|
+
Number(cm.contextWindow) > 0
|
|
49
|
+
? Number(cm.contextWindow)
|
|
50
|
+
: preset?.contextWindow || (isLocal ? UNKNOWN_LOCAL_WINDOW : UNKNOWN_REMOTE_WINDOW);
|
|
51
|
+
const maxOutputTokens =
|
|
52
|
+
Number(cm.maxOutputTokens) > 0
|
|
53
|
+
? Number(cm.maxOutputTokens)
|
|
54
|
+
: preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
|
|
55
|
+
const budgetTokens = preset?.budgetTokens || null;
|
|
56
|
+
return { contextWindow, maxOutputTokens, isLocal, budgetTokens, preset };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 安全 prompt 预算:min(用户配置/预设, 窗口×75% 舒适区, 窗口−输出−余量)。
|
|
61
|
+
* 保证 prompt + maxOutput + 余量 ≤ contextWindow,且 prompt 不越舒适区(prefill 不爆炸)。
|
|
62
|
+
*/
|
|
63
|
+
export function safeBudget(/** @type {any} */ cfg, /** @type {any} */ caps) {
|
|
64
|
+
const ceiling = Math.max(1024, caps.contextWindow - caps.maxOutputTokens - OUTPUT_HEADROOM);
|
|
65
|
+
const comfort = Math.max(1024, Math.floor(caps.contextWindow * COMFORT_RATIO));
|
|
66
|
+
const configured = Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null;
|
|
67
|
+
const base = configured ?? caps.budgetTokens ?? comfort;
|
|
68
|
+
return Math.max(1024, Math.min(base, ceiling, comfort));
|
|
69
|
+
}
|