sage-mem 1.0.0 → 1.0.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/lib/autodream/engine.js +9 -0
- package/lib/autodream/llm-loop.js +99 -13
- package/lib/autodream/report.js +36 -4
- package/package.json +1 -1
package/lib/autodream/engine.js
CHANGED
|
@@ -290,6 +290,8 @@ export class AutodreamEngine {
|
|
|
290
290
|
auditBefore,
|
|
291
291
|
auditAfter,
|
|
292
292
|
finalText: result.finalText,
|
|
293
|
+
continuations: result.continuations,
|
|
294
|
+
truncated: result.truncated,
|
|
293
295
|
archiveMode: policy.mode,
|
|
294
296
|
archiveRequested: policy.requested,
|
|
295
297
|
archiveCandidates: policy.candidates,
|
|
@@ -316,6 +318,8 @@ export class AutodreamEngine {
|
|
|
316
318
|
auditBefore,
|
|
317
319
|
auditAfter,
|
|
318
320
|
finalText: result.finalText,
|
|
321
|
+
continuations: result.continuations,
|
|
322
|
+
truncated: result.truncated,
|
|
319
323
|
transcript: result.transcript,
|
|
320
324
|
tokensIn: result.tokensIn,
|
|
321
325
|
tokensOut: result.tokensOut,
|
|
@@ -341,6 +345,9 @@ export class AutodreamEngine {
|
|
|
341
345
|
problemsAfter: auditAfter.problems,
|
|
342
346
|
tokensIn: result.tokensIn,
|
|
343
347
|
tokensOut: result.tokensOut,
|
|
348
|
+
// 残篇要能在界面上被认出来(面板读 lastResult):只报「成功」是不够的。
|
|
349
|
+
continuations: result.continuations,
|
|
350
|
+
truncated: result.truncated,
|
|
344
351
|
report: report.name,
|
|
345
352
|
snapshotId: snapshot?.id ?? null,
|
|
346
353
|
}
|
|
@@ -353,6 +360,8 @@ export class AutodreamEngine {
|
|
|
353
360
|
touched: result.touched,
|
|
354
361
|
changeCount: result.changes.length,
|
|
355
362
|
snapshotId: snapshot?.id ?? null,
|
|
363
|
+
continuations: result.continuations,
|
|
364
|
+
truncated: result.truncated,
|
|
356
365
|
auditBefore,
|
|
357
366
|
auditAfter,
|
|
358
367
|
}
|
|
@@ -150,10 +150,35 @@ export async function streamOnce(llm, req) {
|
|
|
150
150
|
return asm
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* 一次模型调用的输出上限。
|
|
155
|
+
*
|
|
156
|
+
* ⚠️ **调大它解决不了「长报告被截断」**:报告长度没有上界,调大只是把墙往后挪一格
|
|
157
|
+
* (2026-10-17 实测:10-01 的报告 5532 字符、10-13 是 5957,都完整收尾;这次 8553 字符
|
|
158
|
+
* 就断在半个句子里)。真正的处理是撞上它时**接着写** —— 见下面的 MAX_CONTINUATIONS。
|
|
159
|
+
*/
|
|
160
|
+
const MAX_TOKENS_PER_CALL = 4096
|
|
161
|
+
/** 撞上单次上限后最多续写几轮:够把最后一段写完,又不至于让一次跑飞掉。 */
|
|
162
|
+
const MAX_CONTINUATIONS = 3
|
|
163
|
+
/** 续写指令。三条禁令缺一不可 —— 重写整篇 / 重新开头 / 道歉寒暄,模型都干得出来。 */
|
|
164
|
+
const CONTINUE_PROMPT =
|
|
165
|
+
'(上一条回答被输出长度上限截断了。请**从中断处继续写**,不要重复已经写过的内容,不要重新开头,不要解释或道歉,直接接着写。)'
|
|
166
|
+
|
|
167
|
+
/** 取这一次调用里的全部文字块,拼成一段。 */
|
|
168
|
+
function textOf(asm) {
|
|
169
|
+
return asm
|
|
170
|
+
.blocks()
|
|
171
|
+
.filter((b) => b.type === 'text')
|
|
172
|
+
.map((b) => b.text)
|
|
173
|
+
.join('')
|
|
174
|
+
.trim()
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
/**
|
|
154
178
|
* 极简 agent 循环:模型 → 工具 → 结果回灌 → 再问,直到模型不再要工具或触顶。
|
|
155
179
|
*
|
|
156
180
|
* 每一步都把「这一轮要了什么工具」记进 stepLog,设置面板显示的就是这个。
|
|
181
|
+
* **一步可以包含多次模型调用**:撞上输出上限时接着写,而不是把半篇当成品交出去。
|
|
157
182
|
*/
|
|
158
183
|
export async function agentLoop(cfg, runOpts) {
|
|
159
184
|
const { provider, model, toolSchemas, runner, maxSteps, tokenBudget, signal } = runOpts
|
|
@@ -178,40 +203,90 @@ export async function agentLoop(cfg, runOpts) {
|
|
|
178
203
|
let tokensIn = 0
|
|
179
204
|
let tokensOut = 0
|
|
180
205
|
let finalText = ''
|
|
206
|
+
// 截断与续写的账:`continuations` 是「确实接着写过几轮」,`truncated` 是「续写用尽还是没写完」。
|
|
207
|
+
// 两个都要进声明与报告 —— 「报告是残篇却报成功」正是这一版要修的那处静默。
|
|
208
|
+
let continuations = 0
|
|
209
|
+
let truncated = false
|
|
210
|
+
let lastStep = 0
|
|
181
211
|
const allToolsUsed = []
|
|
212
|
+
const addUsage = (u) => {
|
|
213
|
+
if (!u) return
|
|
214
|
+
tokensIn += u.inputTokens ?? 0
|
|
215
|
+
tokensOut += u.outputTokens ?? 0
|
|
216
|
+
}
|
|
182
217
|
|
|
183
218
|
for (let step = 1; step <= maxSteps; step++) {
|
|
184
219
|
if (signal?.aborted) throw new Error('autodream 被取消')
|
|
185
220
|
// 让工具执行器知道现在是第几轮:模型没自述缘由时,声明要靠这个追溯「哪一步干的」。
|
|
186
221
|
if (typeof runner.setStep === 'function') runner.setStep(step)
|
|
187
222
|
this.phase = `第 ${step} 轮:等待模型`
|
|
188
|
-
|
|
223
|
+
lastStep = step
|
|
224
|
+
|
|
225
|
+
let asm = await this.streamOnce(llm, {
|
|
189
226
|
provider,
|
|
190
227
|
model,
|
|
191
228
|
system,
|
|
192
229
|
messages,
|
|
193
230
|
tools: toolSchemas,
|
|
194
|
-
maxTokens:
|
|
231
|
+
maxTokens: MAX_TOKENS_PER_CALL,
|
|
195
232
|
signal,
|
|
196
233
|
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
234
|
+
addUsage(asm.usage)
|
|
235
|
+
|
|
236
|
+
// 一段 = 一次模型调用产出的文字。正常只有一段;撞上输出上限时接着写下一段。
|
|
237
|
+
let text = ''
|
|
238
|
+
let calls = []
|
|
239
|
+
let segment = 0
|
|
240
|
+
for (;;) {
|
|
241
|
+
const segText = textOf(asm)
|
|
242
|
+
const blocks = asm.blocks()
|
|
243
|
+
const segCalls = blocks.filter((b) => b.type === 'tool-call')
|
|
244
|
+
// 只有第一段可能带工具调用:被截断时 BlockAssembler 已经丢掉了参数残缺的 tool-call。
|
|
245
|
+
if (segment === 0) calls = segCalls
|
|
246
|
+
text += segText
|
|
247
|
+
transcript.push({
|
|
248
|
+
step,
|
|
249
|
+
text: segText,
|
|
250
|
+
tools: segCalls.map((c) => c.name),
|
|
251
|
+
...(segment ? { continuation: true } : {}),
|
|
252
|
+
})
|
|
253
|
+
this.stepLog.push({
|
|
254
|
+
step,
|
|
255
|
+
tools: segCalls.map((c) => c.name),
|
|
256
|
+
chars: segText.length,
|
|
257
|
+
at: Date.now(),
|
|
258
|
+
...(segment ? { continuation: true } : {}),
|
|
259
|
+
})
|
|
260
|
+
segment += 1
|
|
261
|
+
if (asm.finish.kind !== 'max-tokens') break
|
|
262
|
+
// 空段没法接着写;续写名额用尽也一样 —— 两种都算「这篇没写完」,要如实记下来。
|
|
263
|
+
if (!segText || continuations >= MAX_CONTINUATIONS) {
|
|
264
|
+
truncated = true
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
continuations += 1
|
|
268
|
+
messages.push(assistantMessage(blocks, provider, model))
|
|
269
|
+
messages.push(userMessage(CONTINUE_PROMPT))
|
|
270
|
+
this.phase = `第 ${step} 轮:输出撞上单次上限,续写 ${continuations}/${MAX_CONTINUATIONS}`
|
|
271
|
+
asm = await this.streamOnce(llm, {
|
|
272
|
+
provider,
|
|
273
|
+
model,
|
|
274
|
+
system,
|
|
275
|
+
messages,
|
|
276
|
+
tools: toolSchemas,
|
|
277
|
+
maxTokens: MAX_TOKENS_PER_CALL,
|
|
278
|
+
signal,
|
|
279
|
+
})
|
|
280
|
+
addUsage(asm.usage)
|
|
201
281
|
}
|
|
202
|
-
const blocks = asm.blocks()
|
|
203
|
-
const text = blocks.filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
204
|
-
const calls = blocks.filter((b) => b.type === 'tool-call')
|
|
205
|
-
if (text) finalText = text
|
|
206
|
-
transcript.push({ step, text, tools: calls.map((c) => c.name) })
|
|
207
|
-
this.stepLog.push({ step, tools: calls.map((c) => c.name), chars: text.length, at: Date.now() })
|
|
208
282
|
|
|
283
|
+
if (text) finalText = text
|
|
209
284
|
if (asm.finish.kind === 'error') {
|
|
210
285
|
throw new Error(`模型返回错误:${asm.finish.failure?.message ?? '未知'}`)
|
|
211
286
|
}
|
|
212
287
|
if (!calls.length) break
|
|
213
288
|
|
|
214
|
-
messages.push(assistantMessage(blocks, provider, model))
|
|
289
|
+
messages.push(assistantMessage(asm.blocks(), provider, model))
|
|
215
290
|
for (const call of calls) {
|
|
216
291
|
let args = {}
|
|
217
292
|
try {
|
|
@@ -231,11 +306,22 @@ export async function agentLoop(cfg, runOpts) {
|
|
|
231
306
|
}
|
|
232
307
|
}
|
|
233
308
|
|
|
309
|
+
if (truncated) {
|
|
310
|
+
// 过程清单要以「没写完」收尾:否则报告里那半句话看起来只是模型话说到一半。
|
|
311
|
+
transcript.push({
|
|
312
|
+
step: lastStep,
|
|
313
|
+
text: `(输出撞上单次长度上限,续写 ${continuations} 轮后仍未写完 —— 上面这段是残篇,后文缺失)`,
|
|
314
|
+
tools: [],
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
234
318
|
return {
|
|
235
319
|
transcript,
|
|
236
320
|
finalText,
|
|
237
321
|
tokensIn,
|
|
238
322
|
tokensOut,
|
|
323
|
+
continuations,
|
|
324
|
+
truncated,
|
|
239
325
|
toolsUsed: allToolsUsed,
|
|
240
326
|
touched: runner.touchedFiles(),
|
|
241
327
|
changes: runner.changes(),
|
package/lib/autodream/report.js
CHANGED
|
@@ -55,6 +55,9 @@ export async function writeDeclaration(payload) {
|
|
|
55
55
|
},
|
|
56
56
|
report: payload.report ?? null,
|
|
57
57
|
finalText: payload.finalText ?? '',
|
|
58
|
+
// 1.0.1:输出撞上单次上限这件事要进机读记录 —— 残篇在界面上和「话说一半」长得一模一样。
|
|
59
|
+
continuations: Number(payload.continuations) || 0,
|
|
60
|
+
truncated: payload.truncated === true,
|
|
58
61
|
rolledBackAt: 0,
|
|
59
62
|
rolledBackAtHuman: '',
|
|
60
63
|
}
|
|
@@ -75,6 +78,11 @@ export function renderDeclaration(m, headingLevel = 1) {
|
|
|
75
78
|
? `- 回滚点:\`${m.snapshot.id}\`(${m.snapshot.files} 个文件)`
|
|
76
79
|
: '- 回滚点:未做(只出报告模式不写盘,无需回滚)',
|
|
77
80
|
`- 审计:结构问题 ${m.audit.before.problems} → ${m.audit.after.problems}`,
|
|
81
|
+
...(m.truncated
|
|
82
|
+
? [`- ⚠️ 输出被单次长度上限截断:续写 ${m.continuations ?? 0} 轮后仍未写完,收尾说明是**残篇**`]
|
|
83
|
+
: m.continuations
|
|
84
|
+
? [`- 输出曾撞上单次长度上限,已自动续写 ${m.continuations} 轮补齐`]
|
|
85
|
+
: []),
|
|
78
86
|
'',
|
|
79
87
|
`${H}# 改了什么、为什么`,
|
|
80
88
|
'',
|
|
@@ -198,6 +206,20 @@ export async function writeReport(payload) {
|
|
|
198
206
|
const name = `${payload.runId}.md`
|
|
199
207
|
const file = join(dir, name)
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* 输出撞上限这件事**必须在报告第一屏就说**。
|
|
211
|
+
* 为什么:残篇与「模型话说一半」在文件里长得一样,读者无从分辨;而这一版之前,
|
|
212
|
+
* 被截断的那份报告在界面上报的是「成功」。
|
|
213
|
+
*/
|
|
214
|
+
const truncLines = payload.truncated
|
|
215
|
+
? [
|
|
216
|
+
`- ⚠️ **本次输出被单次长度上限截断**:自动续写 ${payload.continuations ?? 0} 轮后仍未写完 —— ` +
|
|
217
|
+
'下面「模型的收尾说明」是**残篇**,后文缺失',
|
|
218
|
+
]
|
|
219
|
+
: payload.continuations
|
|
220
|
+
? [`- 输出曾撞上单次长度上限,已自动续写 ${payload.continuations} 轮补齐(报告完整)`]
|
|
221
|
+
: []
|
|
222
|
+
|
|
201
223
|
const lines = [
|
|
202
224
|
`# Autodream 报告 · ${stampHuman(payload.startedAt)}`,
|
|
203
225
|
'',
|
|
@@ -208,6 +230,7 @@ export async function writeReport(payload) {
|
|
|
208
230
|
`- 模型:\`${payload.provider}/${payload.model}\`${payload.fromDefault ? '(跟随当前会话默认模型)' : '(配置里指定)'}`,
|
|
209
231
|
`- 门控:距上次 ${payload.hoursSince === null ? '(首次)' : payload.hoursSince.toFixed(1) + 'h'} · 期间 ${payload.sessionCount} 个会话更新`,
|
|
210
232
|
`- 耗时:${((payload.endedAt - payload.startedAt) / 1000).toFixed(1)}s · token ${payload.tokensIn} in / ${payload.tokensOut} out`,
|
|
233
|
+
...truncLines,
|
|
211
234
|
payload.snapshot
|
|
212
235
|
? `- 回滚点:\`${payload.snapshot.id}\` · ${payload.snapshot.dir}(${payload.snapshot.count} 个文件)`
|
|
213
236
|
: '- 回滚点:未做(只读模式不写盘)',
|
|
@@ -233,10 +256,19 @@ export async function writeReport(payload) {
|
|
|
233
256
|
payload.declarationMarkdown,
|
|
234
257
|
'## 过程',
|
|
235
258
|
'',
|
|
236
|
-
...payload.transcript.map(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
259
|
+
...payload.transcript.map((t) => {
|
|
260
|
+
// 「续写」必须标出来:否则同一轮出现好几条,读起来像模型重复劳动。
|
|
261
|
+
const mark = t.continuation ? '(截断续写)' : ''
|
|
262
|
+
const kind = t.tools.length ? `:调用 ${t.tools.join('、')}` : ':结束'
|
|
263
|
+
// ⚠️ **先压平空白再截断**。模型这一步的文字里常带换行与 `## 标题`,直接 slice(0,120)
|
|
264
|
+
// 会把标题漏成真标题 —— 报告读起来像「内容重复了一遍」(2026-10-17 10:42 那次真踩到:
|
|
265
|
+
// 「过程」最后一条把下一节的 `## 一、必须修改的…` 顶了出来)。
|
|
266
|
+
const brief = String(t.text ?? '')
|
|
267
|
+
.replace(/\s+/g, ' ')
|
|
268
|
+
.trim()
|
|
269
|
+
.slice(0, 120)
|
|
270
|
+
return `- 第 ${t.step} 轮${mark}${kind}${brief ? ` — ${brief}` : ''}`
|
|
271
|
+
}),
|
|
240
272
|
'',
|
|
241
273
|
]
|
|
242
274
|
await writeFile(file, lines.join('\n'), 'utf8')
|
package/package.json
CHANGED