fix: retry unusable model output in smaller review batches
Leaf batches retry at half and a quarter of the text budget, navigation merges retry once; provider errors are not retried. Adds the long-session acceptance script and records the results.
This commit is contained in:
@@ -4,6 +4,46 @@
|
||||
|
||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
||||
|
||||
## 2026-10-02 长会话验收与 Agent 自主调用图谱
|
||||
|
||||
### 长会话验收
|
||||
|
||||
`npx jiti scripts/long-session-acceptance.mjs <live.sqlite> .env.deepseek.local <dir> <会话前缀> [failAfter]`:对本机数据库做在线备份,只在副本里归档其他会话、隐藏任务,使回顾只读取一条真实会话;首次运行在第 N 次提取时注入一次提供方失败(不消耗付费调用),再继续同一回顾;完成后用保存的批次片段逐条重建每条消息原文;最后再跑一次心跳和一次默认手动回顾,要求零模型调用。原库不修改。
|
||||
|
||||
第一次跑最长会话(389 条消息、约 21.7 万码点、16 天)时,继续回顾三次都失败:两次是模型返回无效 JSON,一次是引用了范围外的来源。原因是一个批次的模型输出不可用时整次回顾立即失败,只能靠反复手动“继续”碰运气。改动:
|
||||
|
||||
- 叶子批次输出不可用(无效 JSON、结构不符、引用不存在的来源或实体)时,在同一段待处理正文上缩小到 1/2、再到 1/4(不少于 500 字符)重试,最多 2 次;缩小只是先处理前一部分,其余正文留在待处理里,不跳过、不截断。
|
||||
- 导航合并输出不可用时,用相同输入再问 1 次。
|
||||
- 提供方错误、超时、取消和响应超出容量不重试,仍按原来的方式失败并保留已保存批次。
|
||||
|
||||
改后结果(DeepSeek `deepseek-flash`):
|
||||
|
||||
| 会话 | 首次运行 | 继续 | 覆盖 | 无变化重跑 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 97 条、6.0 万码点、4.9 天 | 第 4 次提取注入失败,已存 3 批 | 1 次完成,16 次调用 | 97/97 条精确重建,0 缺口、0 重叠 | 心跳、手动均 `no_change`,0 调用 |
|
||||
| 389 条、21.7 万码点、16.2 天 | 第 7 次提取注入失败,已存 5 批 | 1 次完成,61 次调用(30 提取、31 合并),约 32.5 分钟 | 389/389 条精确重建,0 缺口、0 重叠 | 心跳、手动均 `no_change`,0 调用 |
|
||||
|
||||
最长会话共 67 次调用、约 52.7 万输入和 54.8 万输出 Token,发布 237 个事件;失败前保存的批次在继续后全部保留未重算。改动前的同一会话运行(三次继续均失败)记录在 `sl9/long2`,改后中间版本(仅叶子重试,合并失败两次后第三次继续完成)在 `sl9/long3`。
|
||||
|
||||
边界:完整覆盖只说明每段原文都进入了成功批次,不代表模型没有遗漏或误读;验收只用了两条会话,没有测多项目全局回顾。
|
||||
|
||||
### Agent 自主调用
|
||||
|
||||
决定:普通对话不自动注入故事和经验,由 Agent 按需调用 Story Graph 工具。为此:
|
||||
|
||||
- 工具说明写明何时应主动调用(继续之前的工作、询问为什么这样决定、在可能有既有约束的领域开始新任务)、何时不调用(闲聊、与项目历史无关的通用问题),用 2~4 个关键词查询,查几次找不到就说明并按当前上下文回答。
|
||||
- 搜索改为按词匹配:空格和标点分词,中文按相邻两字切分;一条记录命中整句或至少一半的词即返回,按命中程度排序,故事和经验在同分时靠前。此前 Agent 用“监督者回顾调度”这类自然短语搜索,因为要求整句出现而 0 结果。
|
||||
- 搜索结果的预览先显示名称、标题、描述等可读字段,不再以 ID 开头。
|
||||
|
||||
验证(`scripts/story-graph-agent-deepseek.mjs`,DeepSeek 函数调用,只给生产工具定义,不提示要用工具):
|
||||
|
||||
| 轮次 | 判断正确 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 改前 | 4/5 | 通用编程题也去搜索;继续工作类问题搜了 9~22 次仍找不到内容 |
|
||||
| 改后 | 5/5 | 三个项目历史问题都主动调用;数组去重和问候都不调用 |
|
||||
|
||||
改后“继续回顾调度,按最后定的方案”能找到并引用 2026-10-02 的故事线模型设计;“导出功能有什么约束”能列出经验和来源。“为什么取消 300 秒自动暂停”仍在搜了十几次后达到对话轮数上限:这次取消发生在数据副本最后一次回顾之后,图谱里确实还没有这条决定,Agent 没能及时停止并说明“没有记录”。
|
||||
|
||||
## 2026-10-02 故事与经验质量抽查
|
||||
|
||||
数据:同一份 437 个事件的数据副本上已有的故事归属运行(`sl3/off1`、`sl3/cross2`)和经验抽取运行(`sl4/run1`、`sl4/run2`),以及本次改提示词后的新运行(`sl8/off3`,DeepSeek `deepseek-flash`,12 次调用,约 6.2 分钟)。一致性用“两次运行中同属一个功能的事件对”的重合度(Jaccard)衡量。
|
||||
|
||||
@@ -23,7 +23,8 @@ it('production six-leaf merge failure resumes navigation-only JSON and publishes
|
||||
let merges = 0
|
||||
f.respond.mockImplementation(prompt => {
|
||||
if (!prompt.includes('These inputs are navigation summaries')) return leaf(prompt)
|
||||
if (++merges === 2) return { summary: 'Invalid navigation', changeDigest: '', openItems: [], events: [{ sourceReferenceIds: ['invented'] }] }
|
||||
// The second merge is unusable on both attempts (one retry), so the run fails there.
|
||||
if (++merges === 2 || merges === 3) return { summary: 'Invalid navigation', changeDigest: '', openItems: [], events: [{ sourceReferenceIds: ['invented'] }] }
|
||||
return { summary: 'Combined navigation', changeDigest: 'Retained leaf decisions', openItems: ['Check sources'] }
|
||||
})
|
||||
await expect(f.service().run(f.request)).rejects.toThrow()
|
||||
@@ -51,7 +52,7 @@ it.each(['missing arrays', 'unknown source', 'unknown entity'])('production leaf
|
||||
await expect(f.service().run(f.request)).rejects.toThrow()
|
||||
expect(f.db.listSupervisionActivity()[0]!.reviewProgress).toMatchObject({ phase: 'extracting', batches: 0, remainingSources: 1, complete: false })
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(0)
|
||||
expect(f.respond).toHaveBeenCalledTimes(1)
|
||||
expect(f.respond).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('persists publication failure and resumes with saved navigation without model calls', async () => {
|
||||
@@ -214,7 +215,7 @@ it.each(['Atlas', 'optional UUID from KNOWN ENTITIES only', ' ', '', null])('pre
|
||||
expect(f.db.listSupervisionCandidates(f.request)).toEqual(expect.arrayContaining(before))
|
||||
})
|
||||
|
||||
it.each([randomUUID()])('production factory rejects unknown UUID %s without publishing or retrying', async persistedId => {
|
||||
it.each([randomUUID()])('production factory rejects unknown UUID %s without publishing, after the bounded output retries', async persistedId => {
|
||||
const f = fixture()
|
||||
const original = f.respond.getMockImplementation()!
|
||||
f.respond.mockImplementation(prompt => {
|
||||
@@ -224,7 +225,7 @@ it.each([randomUUID()])('production factory rejects unknown UUID %s without publ
|
||||
await expect(f.service().run(f.request)).rejects.toThrow(/leaf batch: entities\[0\].persistedId/)
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(0)
|
||||
expect(f.db.listSupervisionActivity()[0]!.status).toBe('failed')
|
||||
expect(f.respond).toHaveBeenCalledTimes(1)
|
||||
expect(f.respond).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['known_999', 'Atlas', ' ', 'optional candidate reference'])('rejects unknown explicit candidate reference %s', async candidateRef => {
|
||||
@@ -236,7 +237,7 @@ it.each(['known_999', 'Atlas', ' ', 'optional candidate reference'])('rejects un
|
||||
})
|
||||
await expect(f.service().run(f.request)).rejects.toThrow('candidateRef is outside KNOWN ENTITIES')
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(0)
|
||||
expect(f.respond).toHaveBeenCalledTimes(1)
|
||||
expect(f.respond).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('production factory rejects a real UUID from another scope and preserves the existing graph', async () => {
|
||||
@@ -251,7 +252,7 @@ it('production factory rejects a real UUID from another scope and preserves the
|
||||
await expect(f.service().run({ ...f.request, scope: { kind: 'projects', projectIds: [f.db.listProjects()[0]!.id] } })).rejects.toThrow('outside KNOWN ENTITIES')
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(1)
|
||||
expect(f.db.listSupervisionCandidates(f.request)).toEqual(candidates)
|
||||
expect(f.respond).toHaveBeenCalledTimes(2)
|
||||
expect(f.respond).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('reuses actual legacy text-key shapes through strict UUID aliases and preserves confirmed fields and graph links', async () => {
|
||||
|
||||
@@ -249,6 +249,43 @@ it('reopens saved batches after failure, resumes exact Unicode offsets and only
|
||||
expect(f.summarize).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('retries an unusable leaf answer on a smaller part of the same text without skipping any of it', async () => {
|
||||
const f = await fixture([['y'.repeat(2000)]], { concurrency: 1 })
|
||||
// Invalid JSON, then an answer citing a source that is not in the batch, then usable answers.
|
||||
f.summarize.mockResolvedValueOnce('not json' as never).mockImplementationOnce(async () => ({ ...empty,
|
||||
events: [{ title: 'Bad', description: 'x', occurredAt: '2026-09-21T00:00:00.000Z', eventType: 'discussion' as const, entityIds: [], sourceReferenceIds: ['missing'] }] }))
|
||||
const result = await f.service().run(request)
|
||||
expect(result.status).toBe('completed')
|
||||
const evidence = f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence)
|
||||
// Third attempt (a quarter of 1000, at least 500) succeeded; the rest of the text continued at full size.
|
||||
expect(evidence.map(item => item.content.length)).toEqual([500, 1000, 500])
|
||||
expect(evidence.map(item => item.content).join('')).toBe('y'.repeat(2000))
|
||||
})
|
||||
|
||||
it('asks a navigation merge again once when its answer is unusable', async () => {
|
||||
const f = await fixture([['n'.repeat(1500)]], { concurrency: 1 })
|
||||
// Two leaves succeed; the merge first returns invalid JSON, then a usable answer.
|
||||
f.summarize.mockImplementation(async (input: SupervisorSummarizerRequest) => input.evidence[0]?.sourceType === 'note' ? empty
|
||||
: { ...empty, events: input.evidence.map(source => ({ title: 'Fact', description: 'x', occurredAt: source.occurredAt, eventType: 'discussion' as const, entityIds: [], sourceReferenceIds: [source.id] })) })
|
||||
let navigation = 0
|
||||
const base = f.summarize.getMockImplementation()!
|
||||
f.summarize.mockImplementation(async input => input.evidence[0]?.sourceType === 'note' && navigation++ === 0 ? 'not json' as never : base(input))
|
||||
const result = await f.service().run(request)
|
||||
expect(result.status).toBe('completed')
|
||||
expect(navigation).toBe(2)
|
||||
})
|
||||
|
||||
it('fails after the retries and does not retry provider errors', async () => {
|
||||
const f = await fixture([['z'.repeat(1500)]], { concurrency: 1 })
|
||||
f.summarize.mockResolvedValue('not json' as never)
|
||||
await expect(f.service().run(request)).rejects.toThrow('无效 JSON')
|
||||
expect(f.summarize).toHaveBeenCalledTimes(3)
|
||||
const g = await fixture([['z'.repeat(1500)]], { concurrency: 1 })
|
||||
g.summarize.mockRejectedValueOnce(new Error('Provider down'))
|
||||
await expect(g.service().run(request)).rejects.toThrow('Provider down')
|
||||
expect(g.summarize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('continues extraction and navigation beyond the saved execution budget', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1, executionSeconds: 30 })
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(time)
|
||||
|
||||
@@ -170,6 +170,11 @@ function validateReferences(
|
||||
}
|
||||
}
|
||||
|
||||
/** The model answered, but the answer was unusable (bad JSON, schema, or references). Retrying smaller may help. */
|
||||
export class SupervisionOutputError extends Error {}
|
||||
/** Leaf batches with unusable output are retried at half, then a quarter of the text budget, then the run fails. */
|
||||
const outputRetries = 2
|
||||
|
||||
export class SupervisorService {
|
||||
private active?: { settled: Promise<unknown>; runId?: string; stopping?: 'paused' | 'cancelled' }
|
||||
private readonly controllers = new Map<string, AbortController>()
|
||||
@@ -358,9 +363,13 @@ export class SupervisorService {
|
||||
authorizeTool: async name => { throw new Error(`Supervisor tools are disabled: ${name}`) } })
|
||||
} finally { activity.inFlight-- }
|
||||
controller.signal.throwIfAborted()
|
||||
const output = parseModelOutput(raw, navigation ? [] : candidates, `Supervision ${runId} ${navigation ? 'navigation merge' : 'leaf batch'}`, navigation)
|
||||
validateReferences(output, evidence)
|
||||
return output
|
||||
try {
|
||||
const output = parseModelOutput(raw, navigation ? [] : candidates, `Supervision ${runId} ${navigation ? 'navigation merge' : 'leaf batch'}`, navigation)
|
||||
validateReferences(output, evidence)
|
||||
return output
|
||||
} catch (error) {
|
||||
throw new SupervisionOutputError(error instanceof Error ? error.message : String(error), { cause: error })
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
if (controller.signal.aborted) return stopped()
|
||||
@@ -368,11 +377,24 @@ export class SupervisorService {
|
||||
if (!groups.length) break
|
||||
const results = await Promise.allSettled(groups.map(async group => {
|
||||
controller.signal.throwIfAborted()
|
||||
const evidence = db.chunk(runId, group.projectId, group.conversationId, config)
|
||||
if (!evidence.length) throw new Error('Pending source produced no reviewable content')
|
||||
const output = await summarize(evidence, false, await candidatesFor(group.projectId))
|
||||
controller.signal.throwIfAborted()
|
||||
db.save(runId, group.projectId, group.conversationId, evidence, output)
|
||||
const candidates = await candidatesFor(group.projectId)
|
||||
// An unusable answer is retried on a smaller prefix of the same pending text; nothing is skipped,
|
||||
// the rest of the text stays pending for the next batch.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const scale = 2 ** attempt
|
||||
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
|
||||
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
|
||||
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
|
||||
if (!evidence.length) throw new Error('Pending source produced no reviewable content')
|
||||
try {
|
||||
const output = await summarize(evidence, false, candidates)
|
||||
controller.signal.throwIfAborted()
|
||||
db.save(runId, group.projectId, group.conversationId, evidence, output)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
|
||||
}
|
||||
}
|
||||
}))
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
@@ -393,7 +415,12 @@ export class SupervisorService {
|
||||
if (cached) return { id, output: cached }
|
||||
const evidence = [left, right].map(card => ({ id: card.id, sourceType: 'note' as const, sourceId: card.id,
|
||||
title: 'Retained review navigation', content: card.output.summary, occurredAt: request.timeRange.to }))
|
||||
const output = await summarize(evidence, true)
|
||||
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
|
||||
let output: SupervisionSummaryOutput
|
||||
try { output = await summarize(evidence, true) } catch (error) {
|
||||
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
|
||||
output = await summarize(evidence, true)
|
||||
}
|
||||
controller.signal.throwIfAborted()
|
||||
db.saveNavigation(runId, id, [left.id, right.id], output)
|
||||
return { id, output }
|
||||
|
||||
Reference in New Issue
Block a user