dsh-memory-palace 1.7.2 → 1.8.0-alpha.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/README.md +38 -103
- package/lib/api.mjs +3 -5
- package/lib/client.js +39 -84
- package/lib/common/planmode.mjs +32 -0
- package/lib/common/prompts.mjs +6 -8
- package/lib/common/records.mjs +7 -68
- package/lib/common/sections.mjs +35 -11
- package/lib/common/text.mjs +8 -3
- package/lib/distill.mjs +9 -20
- package/lib/hybrid/index.mjs +3 -4
- package/lib/hybrid/subagent.mjs +423 -419
- package/lib/hybrid/tools.mjs +35 -28
- package/lib/index.js +172 -169
- package/lib/projection.mjs +7 -5
- package/lib/tools.mjs +10 -7
- package/package.json +1 -1
package/lib/hybrid/subagent.mjs
CHANGED
|
@@ -1,419 +1,423 @@
|
|
|
1
|
-
// memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
|
|
2
|
-
// 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
|
|
3
|
-
// 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
|
|
4
|
-
// 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
|
|
5
|
-
// 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
|
|
6
|
-
// 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
|
|
7
|
-
// 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
10
|
-
import { join, dirname } from "node:path";
|
|
11
|
-
import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
-
import { todayISO, readMdSync } from "../common/text.mjs";
|
|
13
|
-
import { eventsFrom } from "../common/session.mjs";
|
|
14
|
-
import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection, ensureLogHeader } from "../common/sections.mjs";
|
|
15
|
-
import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
|
|
16
|
-
|
|
17
|
-
export { HYBRID_PROACTIVE };
|
|
18
|
-
|
|
19
|
-
const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
|
|
20
|
-
const MAX_RETRY = 3; // 未知工具触发的催促重试预算(v1.6.3 定为 3)
|
|
21
|
-
const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
|
|
22
|
-
|
|
23
|
-
// 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
|
|
24
|
-
let logWriteChain = Promise.resolve();
|
|
25
|
-
function withLogLock(file, fn) {
|
|
26
|
-
const run = logWriteChain.then(fn, fn);
|
|
27
|
-
logWriteChain = run.catch(() => {});
|
|
28
|
-
return run;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
|
|
32
|
-
// > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
|
|
33
|
-
async function resolveModel(cfg, ctx, session) {
|
|
34
|
-
const sm = (cfg.summaryModel || "").trim();
|
|
35
|
-
if (sm) {
|
|
36
|
-
try {
|
|
37
|
-
const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
|
|
38
|
-
for (const p of providers || []) {
|
|
39
|
-
try {
|
|
40
|
-
const models = await ctx.llm.listModels(p.id);
|
|
41
|
-
const m = (models || []).find((x) => x.id === sm);
|
|
42
|
-
if (m) return { provider: m.provider, model: m.id };
|
|
43
|
-
} catch { /* 该 provider 无模型列表,跳过 */ }
|
|
44
|
-
}
|
|
45
|
-
const provider = sm.split("/")[0].trim();
|
|
46
|
-
if (provider && sm.includes("/")) return { provider, model: sm };
|
|
47
|
-
} catch { /* 注册表不可用,走会话兜底 */ }
|
|
48
|
-
}
|
|
49
|
-
const conf = session?.requestHeader?.()?.config;
|
|
50
|
-
if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
|
|
55
|
-
async function streamTurn(ctx, params, timeoutMs) {
|
|
56
|
-
const signal = AbortSignal.timeout(timeoutMs);
|
|
57
|
-
const res = ctx.llm.stream({ ...params, signal });
|
|
58
|
-
const asm = new BlockAssembler();
|
|
59
|
-
for await (const ch of res) {
|
|
60
|
-
signal.throwIfAborted();
|
|
61
|
-
asm.push(ch);
|
|
62
|
-
}
|
|
63
|
-
const finish = asm.finish;
|
|
64
|
-
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
65
|
-
throw new Error(finish.failure?.message || finish.kind);
|
|
66
|
-
}
|
|
67
|
-
return { finish, asm };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
|
|
71
|
-
// v1.6.2-alpha.4:eventsFrom 兼容层(宿主 0.1.2-alpha.4 删除 Session.events getter)。
|
|
72
|
-
// v1.7.0 特性3(投影去噪):user 侧排除【注入类消息】,只留真实用户对话。
|
|
73
|
-
// 旧实现只看事件类型不看 source,导致首轮 lastSummarizedSeq 较小时把 agent-instructions
|
|
74
|
-
// baseline(AGENTS.md,可达 ~19k 字符)、system-prompt 快照、skill-catalog、以及本插件
|
|
75
|
-
// 自己的 E 投影全部当成"本轮对话"喂给子 agent(污染日志 + 白烧 token)。
|
|
76
|
-
// 判定用【黑名单】而非白名单(fail-open):source 存在且非 'user' 才排除——
|
|
77
|
-
// 注入类消息(agent-instructions / plugin / skill-catalog / skill-invocation …)在 DSH 中
|
|
78
|
-
// 必带 source.kind,故排除效果与白名单等价;而 source 缺失时保留,避免误丢真实用户请求
|
|
79
|
-
// 导致子 agent 空转(与 index.mjs 的 turnBuffer 采集口径保持一致)。
|
|
80
|
-
// assistant/message 与 tool/result 原样保留。
|
|
81
|
-
// 导出供单测直接断言(tests/test-v1.7.0.mjs),运行时仍只在 runMemorySubagent 内调用。
|
|
82
|
-
export function projectTurnMessages(session, fromSeq) {
|
|
83
|
-
const hist = [];
|
|
84
|
-
for (const e of eventsFrom(session, fromSeq)) {
|
|
85
|
-
if (!SURFACE.has(e.type)) continue;
|
|
86
|
-
const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
|
|
87
|
-
if (!m) continue;
|
|
88
|
-
// deriveEventMessage 对 user/message 直接返回 event.data(含 source),故此处可安全读取。
|
|
89
|
-
const srcKind = m.source?.kind;
|
|
90
|
-
if (e.type === "user/message" && srcKind && srcKind !== "user") continue;
|
|
91
|
-
hist.push(m);
|
|
92
|
-
}
|
|
93
|
-
return hist;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
|
|
97
|
-
// 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
|
|
98
|
-
function buildLogBlock(dir, budget) {
|
|
99
|
-
const file = join(dir, `${todayISO()}.md`);
|
|
100
|
-
const text = readMdSync(file);
|
|
101
|
-
if (!text) {
|
|
102
|
-
return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
|
|
103
|
-
}
|
|
104
|
-
if (text.length <= budget) {
|
|
105
|
-
return { file, oversized: false, body: text };
|
|
106
|
-
}
|
|
107
|
-
const { sections } = parseSections(text);
|
|
108
|
-
const toc = sections
|
|
109
|
-
.map((s) => {
|
|
110
|
-
const items = s.lines.slice(1).filter((l) => l.trim());
|
|
111
|
-
const first = items[0]?.trim().slice(0, 80) || "";
|
|
112
|
-
return `- ${s.title}(${items.length} 条,首条:${first})`;
|
|
113
|
-
})
|
|
114
|
-
.join("\n");
|
|
115
|
-
return {
|
|
116
|
-
file,
|
|
117
|
-
oversized: true,
|
|
118
|
-
body:
|
|
119
|
-
`(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
|
|
124
|
-
async function execLoopTool(block, logFiles, cfg) {
|
|
125
|
-
const name = block.name;
|
|
126
|
-
let args = {};
|
|
127
|
-
try {
|
|
128
|
-
args = JSON.parse(block.arguments || "{}");
|
|
129
|
-
} catch {
|
|
130
|
-
return { ok: false, message: "工具参数不是合法 JSON。" };
|
|
131
|
-
}
|
|
132
|
-
try {
|
|
133
|
-
if (name === "log_read_section") {
|
|
134
|
-
// v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
|
|
135
|
-
const file = logFiles[0];
|
|
136
|
-
const md = readMdSync(file) || "";
|
|
137
|
-
const wants = Array.isArray(args.sections)
|
|
138
|
-
? args.sections.filter((s) => s && typeof s === "string")
|
|
139
|
-
: args.section
|
|
140
|
-
? [args.section]
|
|
141
|
-
: [];
|
|
142
|
-
if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
|
|
143
|
-
const parts = [];
|
|
144
|
-
const missing = [];
|
|
145
|
-
for (const s of wants) {
|
|
146
|
-
const loc = locateSection(md, String(s).trim());
|
|
147
|
-
if (!loc) {
|
|
148
|
-
missing.push(String(s).trim());
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
|
|
152
|
-
}
|
|
153
|
-
if (!parts.length) {
|
|
154
|
-
return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
|
|
155
|
-
}
|
|
156
|
-
const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
|
|
157
|
-
return { ok: true, content: parts.join("\n\n---\n\n") + note };
|
|
158
|
-
}
|
|
159
|
-
if (name === "log_write_ops") {
|
|
160
|
-
const ops = Array.isArray(args.ops) ? args.ops : [];
|
|
161
|
-
if (!ops.length) return { ok: false, message: "ops 为空。" };
|
|
162
|
-
return await applyLogOps(logFiles, todayISO(), ops);
|
|
163
|
-
}
|
|
164
|
-
return {
|
|
165
|
-
ok: false,
|
|
166
|
-
unknown: true,
|
|
167
|
-
name,
|
|
168
|
-
message:
|
|
169
|
-
`工具 ${name} 不存在,调用已拒绝。本环境仅提供两个工具:\n` +
|
|
170
|
-
`- log_write_ops:写入今日日志(ops 数组,支持 append / new_section / mark_delete)\n` +
|
|
171
|
-
`- log_read_section:读取今日日志中指定章节(sections 数组)\n` +
|
|
172
|
-
`请立即改用 log_write_ops 提交 ops 完成本轮写入;不要调用其他工具,也不要直接结束。`,
|
|
173
|
-
};
|
|
174
|
-
} catch (e) {
|
|
175
|
-
return { ok: false, message: `执行失败:${e?.message || String(e)}` };
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
|
|
180
|
-
// append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
|
|
181
|
-
// mark_delete=删除线墓碑(结构行保护)。
|
|
182
|
-
function applyLogOps(logFiles, dateStr, ops) {
|
|
183
|
-
const apply = (md) => {
|
|
184
|
-
let cur = md || "";
|
|
185
|
-
const results = [];
|
|
186
|
-
for (const op of ops) {
|
|
187
|
-
if (!op || typeof op !== "object") {
|
|
188
|
-
results.push({ op: "invalid", ok: false, message: "op 非对象" });
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
const section = String(op.section ?? "").trim();
|
|
192
|
-
if (!section) {
|
|
193
|
-
results.push({ op: op.op, ok: false, message: "section 不能为空" });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (op.op === "append") {
|
|
197
|
-
const r = upsertSectionText(cur, section, op.entry);
|
|
198
|
-
results.push({ op: "append", section, ok: r.ok, message: r.reason });
|
|
199
|
-
if (r.ok) cur = r.text;
|
|
200
|
-
} else if (op.op === "new_section") {
|
|
201
|
-
const r = createSectionText(cur, section, op.entry);
|
|
202
|
-
results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
|
|
203
|
-
if (r.ok) cur = r.text;
|
|
204
|
-
} else if (op.op === "mark_delete") {
|
|
205
|
-
const r = markEntryDeletedText(cur, section, op.oldText);
|
|
206
|
-
results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
|
|
207
|
-
if (r.ok && r.text !== undefined) cur = r.text;
|
|
208
|
-
} else {
|
|
209
|
-
results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return { cur, results };
|
|
213
|
-
};
|
|
214
|
-
return withLogLock(logFiles[0], async () => {
|
|
215
|
-
const all = [];
|
|
216
|
-
for (const file of logFiles) {
|
|
217
|
-
let md = "";
|
|
218
|
-
try {
|
|
219
|
-
md = readFileSync(file, "utf8");
|
|
220
|
-
} catch { /* 文件尚不存在 */ }
|
|
221
|
-
const { cur, results } = apply(md);
|
|
222
|
-
// v1.7.0 特性4:落盘前确保文件头为规范 `# YYYY-MM-DD`(空文件/首行空/直接以 ## 开头
|
|
223
|
-
// 三种形态都会补上;已有同日标题则原样)。仅在写入时补齐,不回填历史文件。
|
|
224
|
-
const withHeader = ensureLogHeader(cur, dateStr);
|
|
225
|
-
if (withHeader !== md) {
|
|
226
|
-
await mkdir(dirname(file), { recursive: true });
|
|
227
|
-
await writeFile(file, withHeader, "utf8");
|
|
228
|
-
}
|
|
229
|
-
all.push(...results);
|
|
230
|
-
}
|
|
231
|
-
const applied = all.filter((r) => r.ok).length;
|
|
232
|
-
return { ok: true, applied, total: all.length, results: all, date: dateStr };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
|
|
237
|
-
// 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
|
|
238
|
-
// ok=false
|
|
239
|
-
export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError }) {
|
|
240
|
-
const cfg = getConfig();
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
"
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (result.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
1
|
+
// memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
|
|
2
|
+
// 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
|
|
3
|
+
// 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
|
|
4
|
+
// 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
|
|
5
|
+
// 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
|
|
6
|
+
// 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
|
|
7
|
+
// 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
+
import { todayISO, readMdSync } from "../common/text.mjs";
|
|
13
|
+
import { eventsFrom } from "../common/session.mjs";
|
|
14
|
+
import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection, ensureLogHeader } from "../common/sections.mjs";
|
|
15
|
+
import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
|
|
16
|
+
|
|
17
|
+
export { HYBRID_PROACTIVE };
|
|
18
|
+
|
|
19
|
+
const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
|
|
20
|
+
const MAX_RETRY = 3; // 未知工具触发的催促重试预算(v1.6.3 定为 3)
|
|
21
|
+
const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
|
|
22
|
+
|
|
23
|
+
// 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
|
|
24
|
+
let logWriteChain = Promise.resolve();
|
|
25
|
+
function withLogLock(file, fn) {
|
|
26
|
+
const run = logWriteChain.then(fn, fn);
|
|
27
|
+
logWriteChain = run.catch(() => {});
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
|
|
32
|
+
// > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
|
|
33
|
+
async function resolveModel(cfg, ctx, session) {
|
|
34
|
+
const sm = (cfg.summaryModel || "").trim();
|
|
35
|
+
if (sm) {
|
|
36
|
+
try {
|
|
37
|
+
const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
|
|
38
|
+
for (const p of providers || []) {
|
|
39
|
+
try {
|
|
40
|
+
const models = await ctx.llm.listModels(p.id);
|
|
41
|
+
const m = (models || []).find((x) => x.id === sm);
|
|
42
|
+
if (m) return { provider: m.provider, model: m.id };
|
|
43
|
+
} catch { /* 该 provider 无模型列表,跳过 */ }
|
|
44
|
+
}
|
|
45
|
+
const provider = sm.split("/")[0].trim();
|
|
46
|
+
if (provider && sm.includes("/")) return { provider, model: sm };
|
|
47
|
+
} catch { /* 注册表不可用,走会话兜底 */ }
|
|
48
|
+
}
|
|
49
|
+
const conf = session?.requestHeader?.()?.config;
|
|
50
|
+
if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
|
|
55
|
+
async function streamTurn(ctx, params, timeoutMs) {
|
|
56
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
57
|
+
const res = ctx.llm.stream({ ...params, signal });
|
|
58
|
+
const asm = new BlockAssembler();
|
|
59
|
+
for await (const ch of res) {
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
asm.push(ch);
|
|
62
|
+
}
|
|
63
|
+
const finish = asm.finish;
|
|
64
|
+
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
65
|
+
throw new Error(finish.failure?.message || finish.kind);
|
|
66
|
+
}
|
|
67
|
+
return { finish, asm };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
|
|
71
|
+
// v1.6.2-alpha.4:eventsFrom 兼容层(宿主 0.1.2-alpha.4 删除 Session.events getter)。
|
|
72
|
+
// v1.7.0 特性3(投影去噪):user 侧排除【注入类消息】,只留真实用户对话。
|
|
73
|
+
// 旧实现只看事件类型不看 source,导致首轮 lastSummarizedSeq 较小时把 agent-instructions
|
|
74
|
+
// baseline(AGENTS.md,可达 ~19k 字符)、system-prompt 快照、skill-catalog、以及本插件
|
|
75
|
+
// 自己的 E 投影全部当成"本轮对话"喂给子 agent(污染日志 + 白烧 token)。
|
|
76
|
+
// 判定用【黑名单】而非白名单(fail-open):source 存在且非 'user' 才排除——
|
|
77
|
+
// 注入类消息(agent-instructions / plugin / skill-catalog / skill-invocation …)在 DSH 中
|
|
78
|
+
// 必带 source.kind,故排除效果与白名单等价;而 source 缺失时保留,避免误丢真实用户请求
|
|
79
|
+
// 导致子 agent 空转(与 index.mjs 的 turnBuffer 采集口径保持一致)。
|
|
80
|
+
// assistant/message 与 tool/result 原样保留。
|
|
81
|
+
// 导出供单测直接断言(tests/test-v1.7.0.mjs),运行时仍只在 runMemorySubagent 内调用。
|
|
82
|
+
export function projectTurnMessages(session, fromSeq) {
|
|
83
|
+
const hist = [];
|
|
84
|
+
for (const e of eventsFrom(session, fromSeq)) {
|
|
85
|
+
if (!SURFACE.has(e.type)) continue;
|
|
86
|
+
const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
|
|
87
|
+
if (!m) continue;
|
|
88
|
+
// deriveEventMessage 对 user/message 直接返回 event.data(含 source),故此处可安全读取。
|
|
89
|
+
const srcKind = m.source?.kind;
|
|
90
|
+
if (e.type === "user/message" && srcKind && srcKind !== "user") continue;
|
|
91
|
+
hist.push(m);
|
|
92
|
+
}
|
|
93
|
+
return hist;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
|
|
97
|
+
// 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
|
|
98
|
+
function buildLogBlock(dir, budget) {
|
|
99
|
+
const file = join(dir, `${todayISO()}.md`);
|
|
100
|
+
const text = readMdSync(file);
|
|
101
|
+
if (!text) {
|
|
102
|
+
return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
|
|
103
|
+
}
|
|
104
|
+
if (text.length <= budget) {
|
|
105
|
+
return { file, oversized: false, body: text };
|
|
106
|
+
}
|
|
107
|
+
const { sections } = parseSections(text);
|
|
108
|
+
const toc = sections
|
|
109
|
+
.map((s) => {
|
|
110
|
+
const items = s.lines.slice(1).filter((l) => l.trim());
|
|
111
|
+
const first = items[0]?.trim().slice(0, 80) || "";
|
|
112
|
+
return `- ${s.title}(${items.length} 条,首条:${first})`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n");
|
|
115
|
+
return {
|
|
116
|
+
file,
|
|
117
|
+
oversized: true,
|
|
118
|
+
body:
|
|
119
|
+
`(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
|
|
124
|
+
async function execLoopTool(block, logFiles, cfg) {
|
|
125
|
+
const name = block.name;
|
|
126
|
+
let args = {};
|
|
127
|
+
try {
|
|
128
|
+
args = JSON.parse(block.arguments || "{}");
|
|
129
|
+
} catch {
|
|
130
|
+
return { ok: false, message: "工具参数不是合法 JSON。" };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
if (name === "log_read_section") {
|
|
134
|
+
// v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
|
|
135
|
+
const file = logFiles[0];
|
|
136
|
+
const md = readMdSync(file) || "";
|
|
137
|
+
const wants = Array.isArray(args.sections)
|
|
138
|
+
? args.sections.filter((s) => s && typeof s === "string")
|
|
139
|
+
: args.section
|
|
140
|
+
? [args.section]
|
|
141
|
+
: [];
|
|
142
|
+
if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
|
|
143
|
+
const parts = [];
|
|
144
|
+
const missing = [];
|
|
145
|
+
for (const s of wants) {
|
|
146
|
+
const loc = locateSection(md, String(s).trim());
|
|
147
|
+
if (!loc) {
|
|
148
|
+
missing.push(String(s).trim());
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
|
|
152
|
+
}
|
|
153
|
+
if (!parts.length) {
|
|
154
|
+
return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
|
|
155
|
+
}
|
|
156
|
+
const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
|
|
157
|
+
return { ok: true, content: parts.join("\n\n---\n\n") + note };
|
|
158
|
+
}
|
|
159
|
+
if (name === "log_write_ops") {
|
|
160
|
+
const ops = Array.isArray(args.ops) ? args.ops : [];
|
|
161
|
+
if (!ops.length) return { ok: false, message: "ops 为空。" };
|
|
162
|
+
return await applyLogOps(logFiles, todayISO(), ops);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
unknown: true,
|
|
167
|
+
name,
|
|
168
|
+
message:
|
|
169
|
+
`工具 ${name} 不存在,调用已拒绝。本环境仅提供两个工具:\n` +
|
|
170
|
+
`- log_write_ops:写入今日日志(ops 数组,支持 append / new_section / mark_delete)\n` +
|
|
171
|
+
`- log_read_section:读取今日日志中指定章节(sections 数组)\n` +
|
|
172
|
+
`请立即改用 log_write_ops 提交 ops 完成本轮写入;不要调用其他工具,也不要直接结束。`,
|
|
173
|
+
};
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { ok: false, message: `执行失败:${e?.message || String(e)}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
|
|
180
|
+
// append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
|
|
181
|
+
// mark_delete=删除线墓碑(结构行保护)。
|
|
182
|
+
function applyLogOps(logFiles, dateStr, ops) {
|
|
183
|
+
const apply = (md) => {
|
|
184
|
+
let cur = md || "";
|
|
185
|
+
const results = [];
|
|
186
|
+
for (const op of ops) {
|
|
187
|
+
if (!op || typeof op !== "object") {
|
|
188
|
+
results.push({ op: "invalid", ok: false, message: "op 非对象" });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const section = String(op.section ?? "").trim();
|
|
192
|
+
if (!section) {
|
|
193
|
+
results.push({ op: op.op, ok: false, message: "section 不能为空" });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (op.op === "append") {
|
|
197
|
+
const r = upsertSectionText(cur, section, op.entry);
|
|
198
|
+
results.push({ op: "append", section, ok: r.ok, message: r.reason });
|
|
199
|
+
if (r.ok) cur = r.text;
|
|
200
|
+
} else if (op.op === "new_section") {
|
|
201
|
+
const r = createSectionText(cur, section, op.entry);
|
|
202
|
+
results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
|
|
203
|
+
if (r.ok) cur = r.text;
|
|
204
|
+
} else if (op.op === "mark_delete") {
|
|
205
|
+
const r = markEntryDeletedText(cur, section, op.oldText);
|
|
206
|
+
results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
|
|
207
|
+
if (r.ok && r.text !== undefined) cur = r.text;
|
|
208
|
+
} else {
|
|
209
|
+
results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { cur, results };
|
|
213
|
+
};
|
|
214
|
+
return withLogLock(logFiles[0], async () => {
|
|
215
|
+
const all = [];
|
|
216
|
+
for (const file of logFiles) {
|
|
217
|
+
let md = "";
|
|
218
|
+
try {
|
|
219
|
+
md = readFileSync(file, "utf8");
|
|
220
|
+
} catch { /* 文件尚不存在 */ }
|
|
221
|
+
const { cur, results } = apply(md);
|
|
222
|
+
// v1.7.0 特性4:落盘前确保文件头为规范 `# YYYY-MM-DD`(空文件/首行空/直接以 ## 开头
|
|
223
|
+
// 三种形态都会补上;已有同日标题则原样)。仅在写入时补齐,不回填历史文件。
|
|
224
|
+
const withHeader = ensureLogHeader(cur, dateStr);
|
|
225
|
+
if (withHeader !== md) {
|
|
226
|
+
await mkdir(dirname(file), { recursive: true });
|
|
227
|
+
await writeFile(file, withHeader, "utf8");
|
|
228
|
+
}
|
|
229
|
+
all.push(...results);
|
|
230
|
+
}
|
|
231
|
+
const applied = all.filter((r) => r.ok).length;
|
|
232
|
+
return { ok: true, applied, total: all.length, results: all, date: dateStr };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
|
|
237
|
+
// 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
|
|
238
|
+
// ok=false 表示整体失败(不推进断点、下一次 turn/end 自动补蒸;不降级为任何轻量写入)。
|
|
239
|
+
export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError, getSeq, setSeq }) {
|
|
240
|
+
const cfg = getConfig();
|
|
241
|
+
// 增量断点按会话取用:调用方(index.mjs)注入 getSeq/setSeq(按 sessionId 分桶)。
|
|
242
|
+
// 未注入时回落全局 state.lastSummarizedSeq,保持旧行为与向后兼容。
|
|
243
|
+
const readSeq = typeof getSeq === "function" ? getSeq : () => state.lastSummarizedSeq;
|
|
244
|
+
const writeSeq = typeof setSeq === "function" ? setSeq : (v) => { state.lastSummarizedSeq = v; };
|
|
245
|
+
// 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
|
|
246
|
+
// dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
|
|
247
|
+
// 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
|
|
248
|
+
const dbgFail = (why, extra) => console.error(`[memory-palace] subagent warn: ${why}`, extra ? JSON.stringify(extra) : "");
|
|
249
|
+
const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
|
|
250
|
+
if (!cfg.enabled || !session || !dirs.length) {
|
|
251
|
+
dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
|
|
252
|
+
return { ok: false, mode: "disabled" };
|
|
253
|
+
}
|
|
254
|
+
const resolved = await resolveModel(cfg, ctx, session);
|
|
255
|
+
if (!resolved) {
|
|
256
|
+
dbgFail("no model", { summaryModel: cfg.summaryModel });
|
|
257
|
+
return { ok: false, mode: "no-model" };
|
|
258
|
+
}
|
|
259
|
+
const { provider, model } = resolved;
|
|
260
|
+
dbg("entry", { sessionId: session.id, fromSeq: readSeq(), provider, model });
|
|
261
|
+
|
|
262
|
+
const hist = projectTurnMessages(session, readSeq());
|
|
263
|
+
if (!hist.length) {
|
|
264
|
+
dbg("no events in range", { fromSeq: readSeq() });
|
|
265
|
+
return { ok: true, mode: "noop" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
|
|
269
|
+
const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
|
|
270
|
+
dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
|
|
271
|
+
|
|
272
|
+
// 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
|
|
273
|
+
const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
|
|
274
|
+
const kickoff = createUserMessage({
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: "text",
|
|
278
|
+
text:
|
|
279
|
+
`【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
|
|
280
|
+
`请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
source: { kind: "plugin:dsh-memory-palace" },
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const tools = [
|
|
287
|
+
{
|
|
288
|
+
name: "log_read_section",
|
|
289
|
+
description:
|
|
290
|
+
"读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
|
|
291
|
+
parameters: {
|
|
292
|
+
type: "object",
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
properties: {
|
|
295
|
+
sections: {
|
|
296
|
+
type: "array",
|
|
297
|
+
description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
|
|
298
|
+
items: { type: "string" },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
required: ["sections"],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "log_write_ops",
|
|
306
|
+
description:
|
|
307
|
+
"批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
|
|
308
|
+
'- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
|
|
309
|
+
'- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
|
|
310
|
+
'- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
|
|
311
|
+
"一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
|
|
312
|
+
parameters: {
|
|
313
|
+
type: "object",
|
|
314
|
+
additionalProperties: false,
|
|
315
|
+
properties: {
|
|
316
|
+
ops: {
|
|
317
|
+
type: "array",
|
|
318
|
+
description: "日志写操作数组(见 description)。",
|
|
319
|
+
items: {
|
|
320
|
+
type: "object",
|
|
321
|
+
additionalProperties: false,
|
|
322
|
+
properties: {
|
|
323
|
+
op: { type: "string", description: "append | new_section | mark_delete" },
|
|
324
|
+
section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
|
|
325
|
+
entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
|
|
326
|
+
oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
|
|
327
|
+
},
|
|
328
|
+
required: ["op", "section"],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
required: ["ops"],
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const system = SUBAGENT_SYSTEM();
|
|
338
|
+
const messages = [...hist, kickoff];
|
|
339
|
+
const timeoutMs = cfg.summaryTimeoutMs || 60000;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
// v1.6.3:本轮工具执行统计——appliedWrites=成功落盘 ops 数、toolAttempts=调用过工具次数、
|
|
343
|
+
// unknownHits/unknownNames=误调未知工具次数与点名(供 stop 三态判定与催促重试文案)。
|
|
344
|
+
let appliedWrites = 0;
|
|
345
|
+
let toolAttempts = 0;
|
|
346
|
+
let unknownHits = 0;
|
|
347
|
+
let retries = 0;
|
|
348
|
+
const unknownNames = new Set();
|
|
349
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
350
|
+
const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
|
|
351
|
+
if (finish.kind === "stop") {
|
|
352
|
+
// v1.6.3 stop 三态判定(修复:误调未知工具后模型直接收尾 → 日志丢失且断点被无脑推进)。
|
|
353
|
+
// ① 有落盘 → 正常完成,推进断点。
|
|
354
|
+
if (appliedWrites > 0) {
|
|
355
|
+
writeSeq(session.seq);
|
|
356
|
+
dbg("done", { round, mode: "written", appliedWrites });
|
|
357
|
+
return { ok: true, mode: "written" };
|
|
358
|
+
}
|
|
359
|
+
// ② 从未调用任何工具 → 模型判定本轮无实质内容(noop),推进断点。
|
|
360
|
+
if (toolAttempts === 0) {
|
|
361
|
+
writeSeq(session.seq);
|
|
362
|
+
dbg("done", { round, mode: "noop" });
|
|
363
|
+
return { ok: true, mode: "noop" };
|
|
364
|
+
}
|
|
365
|
+
// ③ 命中过未知工具且零写入 → 回告工具清单 + 催促重试(预算 MAX_RETRY 次)。
|
|
366
|
+
if (unknownHits > 0 && retries < MAX_RETRY) {
|
|
367
|
+
retries++;
|
|
368
|
+
const names = [...unknownNames].join("、");
|
|
369
|
+
messages.push(createUserMessage({
|
|
370
|
+
content: [{ type: "text", text:
|
|
371
|
+
`你刚才调用了不存在的工具(${names}),且尚未向日志写入任何内容,任务未完成。\n` +
|
|
372
|
+
`本轮唯一可用的写入工具是 log_write_ops(ops 支持 append / new_section / mark_delete);` +
|
|
373
|
+
`需要读取章节时用 log_read_section。请立即重新提交写入。\n` +
|
|
374
|
+
`若你重新判定本轮确实没有值得记录的内容,请输出一行纯文本说明后结束。` }],
|
|
375
|
+
source: { kind: "plugin:dsh-memory-palace" },
|
|
376
|
+
}));
|
|
377
|
+
dbgFail("retry after unknown tool", { retries, names });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// ④ 重试预算耗尽 / 已知工具用错后自主放弃 → 零写入,不推进断点(下一轮 turn/end 补蒸)。
|
|
381
|
+
dbgFail("no write applied", { round, toolAttempts, unknownHits, retries });
|
|
382
|
+
return { ok: false, mode: "no-write" };
|
|
383
|
+
}
|
|
384
|
+
if (finish.kind !== "tool-calls") {
|
|
385
|
+
// max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
|
|
386
|
+
// v1.6.0 定案:不降级为轻量原文写入(无格式原文会破坏日志章节化结构),宁缺勿滥。
|
|
387
|
+
dbgFail(`finish ${finish.kind}`, { round });
|
|
388
|
+
return { ok: false, mode: `finish-${finish.kind}` };
|
|
389
|
+
}
|
|
390
|
+
messages.push(asm.message({ kind: "model", provider, model }));
|
|
391
|
+
const calls = asm.blocks().filter((b) => b.type === "tool-call");
|
|
392
|
+
dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
|
|
393
|
+
for (const call of calls) {
|
|
394
|
+
toolAttempts++;
|
|
395
|
+
const result = await execLoopTool(call, logFiles, cfg);
|
|
396
|
+
if (result.unknown) {
|
|
397
|
+
unknownHits++;
|
|
398
|
+
unknownNames.add(call.name);
|
|
399
|
+
}
|
|
400
|
+
if (result.ok && result.applied > 0) appliedWrites += result.applied;
|
|
401
|
+
const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
|
|
402
|
+
messages.push(
|
|
403
|
+
createToolResultMessage({
|
|
404
|
+
callId: call.id,
|
|
405
|
+
content: [{ type: "text", text: body }],
|
|
406
|
+
isError: !result.ok,
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
|
|
410
|
+
else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
|
|
414
|
+
return { ok: false, mode: "max-rounds" };
|
|
415
|
+
} catch (e) {
|
|
416
|
+
if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
|
|
417
|
+
dbgFail("timeout", { timeoutMs });
|
|
418
|
+
return { ok: false, mode: "timeout" };
|
|
419
|
+
}
|
|
420
|
+
dbgFail("error", { message: e?.message || String(e) });
|
|
421
|
+
return { ok: false, mode: "error", message: e?.message || String(e) };
|
|
422
|
+
}
|
|
423
|
+
}
|