Skip to content

fix(memory): don't advance AutoMemory extract cursor when the agent makes zero tool calls - #6398

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
lcheng321:fix/automemory-cursor-hallucination-6311
Jul 7, 2026
Merged

fix(memory): don't advance AutoMemory extract cursor when the agent makes zero tool calls#6398
wenshao merged 1 commit into
QwenLM:mainfrom
lcheng321:fix/automemory-cursor-hallucination-6311

Conversation

@lcheng321

Copy link
Copy Markdown
Contributor

Fixes #6311

The extract cursor previously advanced unconditionally after the forked extractor agent reported completed, even when it made zero real tool calls, for example a small or local model hallucinating a bash command instead of calling write_file. This silently and permanently skipped those history messages from being reprocessed.

Also fixes extractionAgentPlanner.ts using filesTouched, which is attempted and unconfirmed paths, instead of filesWritten, which is confirmed successful writes, when deriving touchedTopics. This matches the pattern already used in remember.ts.

touchedTopics.length greater than 0 alone is not sufficient to gate the cursor advance. A legitimate nothing durable to save outcome also produces an empty touchedTopics array and would otherwise be treated the same as a hallucinated run. A new hasToolActivity signal, derived from filesTouched which includes read only calls like read_file, distinguishes agent engaged with the task and found nothing new to save, which is a legitimate noop where the cursor still advances, from agent made zero tool calls at all, which is the hallucination case where the cursor is held for retry.

What this PR does

This PR changes two files in packages/core/src/memory. extract.ts now only advances processedOffset to the full history length when the extractor agent made genuine progress, meaning touchedTopics.length is greater than 0 or hasToolActivity is true. Otherwise it holds the cursor at its previous offset so the same slice of history gets retried on the next extraction pass instead of being silently skipped forever. extractionAgentPlanner.ts adds a new hasToolActivity field to the AutoMemoryExtractionExecutionResult interface, which is true whenever the agent made at least one real tool call, and switches touchedTopics derivation from filesTouched to filesWritten so only confirmed writes count toward topics touched.

Why it's needed

Issue 6311 shows a local model called liquid slash lfm2 1.2b hallucinating a bash script as plain text instead of calling write_file during managed AutoMemory extraction. The extractor agent still reported status completed, so the cursor advanced past those messages unconditionally. The user's remember this instruction was silently dropped and could never be retried, even in a later session with a stronger model. The triage comment suggested gating the cursor advance on touchedTopics.length greater than 0, but that alone cannot distinguish a legitimate agent checked existing memory and decided there is nothing new to save outcome from the hallucination case, since both produce an empty touchedTopics array. The hasToolActivity signal fixes that ambiguity by checking whether the agent made any tool call at all, including read only ones like read_file, which is the actual signature of a hallucinated run in the issue log.

Reviewer Test Plan

How to verify

This is a pure unit test level fix. runAutoMemoryExtractionByAgent is fully mocked in extract.test.ts at packages/core/src/memory/extract.test.ts, so no real LLM calls are needed to reproduce or verify this.

Step one, reproduce the bug before the fix. With only the new test added and the source fix not yet applied, run this single test in isolation.
cd packages/core
npx vitest run src/memory/extract.test.ts -t "BUG #6311"

This produces a failing test with the following output.
FAIL src/memory/extract.test.ts
BUG #6311: should NOT advance cursor when agent makes zero tool calls (hallucination)
AssertionError: expected 1 to be +0
Expected: 0
Received: 1

Received 1 is the bug itself. Even though the agent made zero tool calls, the cursor still advanced past the single unprocessed message, meaning the instruction Remember that I prefer pnpm over npm would never be retried and would be lost forever.

Step two, apply the fix in extract.ts and extractionAgentPlanner.ts. Rerun the same test.
npx vitest run src/memory/extract.test.ts -t "BUG #6311"

This now passes.
PASS src/memory/extract.test.ts
BUG #6311: should NOT advance cursor when agent makes zero tool calls (hallucination)

Step three, confirm no regressions across every file touched by this change.
npx vitest run src/memory/extract.test.ts
Result: 16 passed, 16 total.
npx vitest run src/memory/extractionAgentPlanner.test.ts
Result: 11 passed, 11 total.
npx vitest run src/memory/extractAgent.test.ts
Result: 1 passed, 1 total.
npx vitest run src/memory/memoryLifecycle.integration.test.ts
Result: 1 passed, 1 total.
npx vitest run src/memory/remember.test.ts
Result: 11 passed, 11 total.

A companion regression test named should advance cursor on legitimate noop confirms the fix does not overcorrect. When the agent genuinely checked memory and found nothing new, meaning hasToolActivity is true and touchedTopics is empty, the cursor still advances normally to the full history length.

Step four, confirm lint and typecheck are clean.
npm run lint
Result: no errors.
npm run typecheck --workspace=packages/core
Result: no errors.

Evidence (Before & After)

This is a non UI, backend only logic fix, so there are no screenshots. The evidence is the exact numeric value of processedOffset before and after the fix, shown through the test assertion output above.

Before the fix, for the hallucination scenario where the agent completes with zero tool calls, processedOffset was 1 when it should have stayed at 0. This is a silent numeric error, not a crash and not a visible error message to the end user, which is exactly why it went unnoticed until someone inspected the cursor file directly, as described in the original issue.
before

After the fix, for that same hallucination scenario, processedOffset correctly stays at 0, so the unprocessed message will be retried on the next extraction pass.
after

For the legitimate noop scenario, where the agent has hasToolActivity true but touchedTopics empty, processedOffset correctly advances to the full history length both before and after the fix, confirming the fix only changes behavior for the true hallucination case and does not regress the normal skip logic.

Summary table.

Scenario processedOffset before fix processedOffset after fix Correct behavior
Hallucination, zero tool calls 1, wrong 0, correct Retry on next pass
Legitimate noop, has tool activity full length, correct full length, correct No regression
Normal write full length, correct full length, correct No regression

Tested on

OS Status
macOS not tested
Windows tested
Linux not tested

Environment (optional)

Local run using npx vitest against packages/core on Windows, Node v24.17.0. runAutoMemoryExtractionByAgent is mocked at the unit test layer throughout, so no real model or sandbox environment is involved in this verification.

Risk & Scope

Main risk or tradeoff: if hasToolActivity is ever computed incorrectly for a legitimate run, for example an agent that only reads memory but genuinely has nothing to save, the cursor could fail to advance and the same history slice would be retried indefinitely. The included legitimate noop regression test guards against this specific case.

Not validated or out of scope: behavior against a real local or small model actually hallucinating, as in the original issue log, was not re verified end to end. This fix targets the exact mechanism described in the issue, meaning zero tool calls on a completed run, at the unit test level. The companion issue 6308 about configurable extractor timeouts is related but not addressed here.

Breaking changes or migration notes: AutoMemoryExtractionExecutionResult gains a new required field hasToolActivity. Any other caller constructing this type, whether in test mocks or elsewhere, will need to supply it. This PR updates all in repo mocks accordingly.

Linked Issues

Fixes #6311

中文说明

修复 6311 号问题

之前 extract cursor 在 forked extractor agent 报告 completed 状态后会无条件推进,即使 agent 实际上没有做任何真正的工具调用,例如本地或小模型把 bash 命令幻觉成了纯文本,而不是调用 write_file。这会导致这些历史消息被永久性地静默跳过,再也不会被重新处理。

同时修复了 extractionAgentPlanner.ts 中使用 filesTouched,也就是尝试写入但未确认成功的路径,而不是 filesWritten,也就是确认成功写入的路径,来推导 touchedTopics 的问题,使其与 remember.ts 中已有的写法保持一致。

仅凭 touchedTopics.length 大于 0 不足以作为 cursor 推进的判断条件。合法的没有新东西需要保存的结果同样会产生空的 touchedTopics 数组,如果只看这个字段,会和幻觉场景混为一谈。新增的 hasToolActivity 信号,从 filesTouched 派生,包含只读调用如 read_file,能够区分 agent 认真检查过确实没有新东西这种合法 noop,此时 cursor 正常推进,和 agent 完全没有调用任何工具这种幻觉场景,此时 cursor 保持不变以便重试。

这个 PR 做了什么

在 packages 下 core 下 src 下 memory 目录中改动了两个文件。extract.ts 现在只有在 extractor agent 确实取得了实质性进展时,也就是 touchedTopics.length 大于 0 或者 hasToolActivity 为 true,才会将 processedOffset 推进到完整的 history 长度。否则 cursor 保持在之前的 offset,让同一段历史消息在下一次提取时被重新处理,而不是被永久跳过。extractionAgentPlanner.ts 在 AutoMemoryExtractionExecutionResult 接口中新增了 hasToolActivity 字段,只要 agent 做过至少一次真正的工具调用就为 true,并将 touchedTopics 的推导来源从 filesTouched 切换为 filesWritten,确保只有确认成功的写入才会被计入涉及的主题。

为什么需要这个改动

6311 号问题中展示了一个本地模型在 managed AutoMemory 提取过程中,把一段 bash 脚本当作纯文本吐出来,而不是调用 write_file 工具。extractor agent 依然报告了 completed 状态,导致 cursor 无条件跳过了这些消息,用户记住这件事的指令被静默丢弃,而且永远无法重试,即使之后换成更强的模型也不行。分类评论建议仅凭 touchedTopics.length 大于 0 来判断,但这无法区分 agent 认真检查了现有记忆判断没有新东西需要保存这种合法结果和幻觉场景,两者产生的 touchedTopics 都是空数组。hasToolActivity 信号通过检查 agent 是否做过任何工具调用,包括只读调用如 read_file,解决了这个歧义,这正是问题日志中幻觉场景的真实特征。

Reviewer 测试方案

这是一个纯单元测试层面的修复,extract.test.ts 中 runAutoMemoryExtractionByAgent 被完全 mock 掉,因此复现和验证都不需要真实的 LLM 调用。

第一步,在修复前复现 bug。只加上新测试但源码还没改的情况下,单独跑这一个测试,命令是进入 packages 下 core 目录,然后跑 npx vitest run,指定 extract.test.ts 文件,参数加上 t 和 BUG 6311。这会产生一个失败的测试,输出显示期望是 0,实际收到的是 1。收到 1 就是 bug 本身,即使 agent 零工具调用,cursor 依然跳过了这条唯一未处理的消息,意味着记住我更喜欢用 pnpm 而不是 npm 这条指令永远不会被重试,会永久丢失。

第二步,应用 extract.ts 和 extractionAgentPlanner.ts 里的修复,重新跑同一个测试,这次测试通过。

第三步,确认所有涉及文件都没有回归。分别跑 extract.test.ts,结果 16 个测试全部通过。跑 extractionAgentPlanner.test.ts,结果 11 个测试全部通过。跑 extractAgent.test.ts,结果 1 个测试通过。跑 memoryLifecycle.integration.test.ts,结果 1 个测试通过。跑 remember.test.ts,结果 11 个测试全部通过。配套新增的回归测试确认这个修复没有矫枉过正,当 agent 确实检查过记忆判断没有新东西时,cursor 依然正常推进。

第四步,确认 lint 和 typecheck 都干净,两者均无报错通过。

证据,修复前和修复后

这是一个非 UI、纯后端逻辑修复,所以没有截图。证据就是 processedOffset 这个数值在修复前后的具体变化。

修复前,对于 agent 完成时零工具调用的幻觉场景,processedOffset 是 1,但本应保持为 0。这是一个静默的数值错误,不是崩溃,也不是给最终用户看到的报错提示,这正是为什么这个问题一直没被发现,直到有人直接检查 cursor 文件才发现,正如原始 issue 里描述的那样。

修复后,同样的幻觉场景下,processedOffset 正确地保持为 0,这样未处理的消息会在下一次提取时被重试。

对于合法 noop 场景,也就是 agent 有工具活动但 touchedTopics 为空,修复前后 processedOffset 都正确地推进到完整长度,确认这个修复只改变了真正幻觉场景下的行为,没有破坏正常的跳过逻辑。

测试环境

Windows 已测试,macOS 和 Linux 未测试。本地在 Windows 上通过 npx vitest run 针对 packages 下 core 跑测试,Node 版本 v24.17.0。全程 runAutoMemoryExtractionByAgent 都在单元测试层被 mock,不涉及真实模型或沙箱环境。

风险与范围

主要风险,如果 hasToolActivity 在某个合法场景下被错误计算,比如 agent 只是读取了记忆但确实没有需要保存的东西却被误判,cursor 可能无法推进,导致同一段历史消息被无限重试。已包含的合法 noop 回归测试专门针对这种情况做了防护。

未验证或超出范围,没有针对真实的本地或小模型实际产生幻觉的场景做端到端复测,本修复在单元测试层面针对 issue 中描述的确切机制进行了修复,也就是 completed 状态下零工具调用。相关的 6308 号问题,关于可配置 extractor 超时,与本修复相关但未在此一并处理。

破坏性改动或迁移说明,AutoMemoryExtractionExecutionResult 新增了一个必填字段 hasToolActivity,任何其他构造该类型的调用方,无论是测试 mock 还是其他地方,都需要补上这个字段,本 PR 已同步更新了仓库内所有相关 mock。

…o tool calls

Fixes QwenLM#6311

The extract cursor previously advanced unconditionally after the
forked extractor agent reported 'completed', even when it made zero
real tool calls (e.g. a small/local model hallucinating a bash
command instead of calling write_file). This silently and
permanently skipped those history messages from being reprocessed.

Also fixes extractionAgentPlanner.ts using filesTouched (attempted
paths, unconfirmed) instead of filesWritten (confirmed successful
writes) when deriving touchedTopics, matching the pattern already
used in remember.ts.

touchedTopics.length > 0 alone is not sufficient to gate the cursor
advance: a legitimate 'nothing durable to save' outcome also produces
an empty touchedTopics array and would otherwise be treated the same
as a hallucinated run. A new hasToolActivity signal (derived from
filesTouched, which includes read-only calls like read_file)
distinguishes 'agent engaged with the task and found nothing new to
save' (legitimate noop, cursor still advances) from 'agent made zero
tool calls at all' (hallucination, cursor held for retry).
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @lcheng321!

Template looks good ✓

Problem: This is an observed bug with solid evidence. Issue #6311 includes a full extractor subagent log showing a local model (liquid/lfm2-1.2b) hallucinating a bash script as plain text output instead of calling write_file, while the extraction cursor silently advanced past those messages. The cursor JSON confirms processedOffset: 4 despite zero real tool activity. This is a real silent data loss scenario — the user's "remember this" instruction was permanently dropped.

Direction: AutoMemory extraction reliability is squarely within core scope. The fix targets the exact mechanism described in the issue: unconditional cursor advance on agent completion regardless of whether the agent actually did anything. No CHANGELOG reference, but the area is clearly relevant.

Approach: The scope feels right — 2 source files, 4 test files, all tightly focused. The hasToolActivity signal (derived from filesTouched.length > 0, which includes read-only calls like read_file) cleanly distinguishes the hallucination case (zero tool calls → hold cursor for retry) from the legitimate noop case (agent checked memory, found nothing new → advance normally). The separate fix switching touchedTopics derivation from filesTouched to filesWritten is a related correctness improvement that makes sense to bundle.

Moving on to code review. 🔍

中文说明

感谢 PR,@lcheng321

模板完整 ✓

问题: 这是一个已观测到的 bug,有充分证据。6311 号 issue 包含完整的 extractor 子代理日志,显示一个本地模型(liquid/lfm2-1.2b)将 bash 脚本作为纯文本输出,而不是调用 write_file,同时提取 cursor 静默跳过了那些消息。cursor JSON 确认 processedOffset: 4,但实际零工具活动。这是真正的静默数据丢失场景——用户的"记住这个"指令被永久丢弃了。

方向: AutoMemory 提取可靠性完全在核心范围内。修复精确针对 issue 中描述的机制:agent 完成时无条件推进 cursor,不管 agent 实际做了什么。CHANGELOG 中无直接参考,但该领域明显相关。

方案: 范围合理——2 个源文件,4 个测试文件,全部紧密聚焦。hasToolActivity 信号(从 filesTouched.length > 0 派生,包含只读调用如 read_file)清晰区分了幻觉场景(零工具调用→保持 cursor 等待重试)和合法 noop 场景(agent 检查了 memory 发现没有新内容→正常推进)。将 touchedTopics 推导从 filesTouched 切换为 filesWritten 的独立修复是一个相关的正确性改进,放在一起合理。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading diff): I would add a boolean signal to the extraction result indicating whether the agent made any real tool calls, then gate cursor advance on that signal. The signal should distinguish hallucination (zero tool activity) from legitimate noop (agent checked existing memory, found nothing new). I'd also fix the filesTouchedfilesWritten inconsistency for touchedTopics derivation.

Comparison: The PR's approach matches my proposal closely. It adds hasToolActivity (derived from filesTouched.length > 0, which captures all file-involving tool calls including read_file, glob, grep) and gates cursor advance on touchedTopics.length > 0 || hasToolActivity. The filesWritten ?? [] nullish coalescing for the touchedTopics derivation is correct — filesWritten is optional on ForkedAgentResult and defensive handling matches the pattern in remember.ts.

No correctness bugs, security holes, or regressions found. The code follows project conventions. Two test cases — one for the hallucination bug (hasToolActivity: false) and one for the legitimate noop (hasToolActivity: true, touchedTopics: []) — cover both sides of the distinction.

Real-Scenario Testing

This is a backend logic fix in the AutoMemory extraction pipeline. The bug only triggers when a local/small model hallucinating tool calls as plain text — cannot be reproduced on-demand with the available model. The evidence is the unit test before/after.

Installed build (v0.19.1) — basic sanity

$ qwen -p 'What is 2+2? Reply with just the number.'
4

PR code — auto-memory extraction path

$ npm run dev -- -p 'Remember that I prefer pnpm over npm.'

> @qwen-code/qwen-code@0.19.6 dev
> node scripts/dev.js -p Remember that I prefer pnpm over npm.

Noted — I'll use `pnpm` instead of `npm` for package management commands. Unfortunately, I don't have file-writing tools available in my current toolset to save this to persistent memory, but I'll keep this preference in mind throughout our session.

Extraction pipeline runs without errors. No regressions in the happy path.

Unit Test Evidence

All 5 test files pass (40 tests total):

✓ extract.test.ts — 16 passed (includes BUG #6311 test + legitimate noop regression test)
✓ extractionAgentPlanner.test.ts — 11 passed
✓ extractAgent.test.ts — 1 passed
✓ memoryLifecycle.integration.test.ts — 1 passed
✓ remember.test.ts — 11 passed

Bug regression verified: Temporarily reverted the fix in extract.ts (restored processedOffset: params.history.length) and ran the bug test in isolation:

FAIL src/memory/extract.test.ts > BUG #6311: should NOT advance cursor when agent makes zero tool calls (hallucination)
AssertionError: expected 1 to be +0
- Expected: 0
+ Received: 1

This confirms the test actually catches the bug — processedOffset was 1 (cursor incorrectly advanced) without the fix, and is 0 (held for retry) with the fix.

Lint: clean (eslint on both changed source files).

中文说明

代码审查

独立方案(阅读 diff 前): 我会在提取结果中添加一个布尔信号,表示 agent 是否做过真正的工具调用,然后根据该信号来控制 cursor 推进。该信号需要区分幻觉(零工具活动)和合法 noop(agent 检查了现有 memory 发现没有新内容)。同时修复 touchedTopics 推导中 filesTouchedfilesWritten 的不一致性。

对比: PR 的方案与我的独立方案高度一致。添加了 hasToolActivity(从 filesTouched.length > 0 派生,捕获所有涉及文件的工具调用,包括 read_fileglobgrep),并以 touchedTopics.length > 0 || hasToolActivity 来控制 cursor 推进。touchedTopics 推导中的 filesWritten ?? [] 空值合并是正确的——filesWrittenForkedAgentResult 上是可选的,防御性处理与 remember.ts 中的写法一致。

未发现正确性 bug、安全漏洞或回归。代码遵循项目规范。两个测试用例——一个针对幻觉 bug(hasToolActivity: false),一个针对合法 noop(hasToolActivity: true, touchedTopics: [])——覆盖了该区分的两面。

真实场景测试

这是 AutoMemory 提取管道中的后端逻辑修复。该 bug 仅在本地/小模型将工具调用幻觉为纯文本时触发——无法用可用模型按需复现。证据来自单元测试的 before/after。

安装版本(v0.19.1)基本功能正常,PR 代码的 auto-memory 提取路径运行无错误,正常路径无回归。

单元测试证据

5 个测试文件全部通过(共 40 个测试)。

Bug 回归已验证: 临时撤销 extract.ts 中的修复后,bug 测试失败:expected 1 to be +0,确认测试确实能捕获该 bug。

Lint: 干净。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, well-targeted fix for a real bug. The evidence chain is solid: issue #6311 provides observed log data showing a local model hallucinating bash as plain text during AutoMemory extraction, the cursor JSON confirming silent advancement, and the PR's unit test correctly reproduces the failure without the fix (processedOffset was 1, should be 0) and passes with it.

The hasToolActivity signal — derived from filesTouched.length > 0 (all file-involving tool calls, not just writes) — is the right abstraction. It cleanly separates hallucination (zero tool activity → hold cursor for retry) from legitimate noop (agent genuinely checked memory, found nothing new → advance normally). The companion regression test for the noop case guards against overcorrection.

Switching touchedTopics derivation from filesTouched to filesWritten is a related correctness improvement that makes sense bundled here — only confirmed writes should count toward "topics touched."

40 tests pass across all 5 affected test files. Lint clean. No scope creep, no drive-by refactors, no over-engineering. Ships what it promises.

Approving. ✅

中文说明

这是一个干净、精准的修复,针对一个真实的 bug。证据链完整:6311 号 issue 提供了观测到的日志数据,显示本地模型在 AutoMemory 提取过程中将 bash 幻觉为纯文本,cursor JSON 确认了静默推进,PR 的单元测试在没有修复的情况下正确复现了失败(processedOffset 为 1,应为 0),有修复后通过。

hasToolActivity 信号——从 filesTouched.length > 0 派生(所有涉及文件的工具调用,不仅是写入)——是正确的抽象。它清晰地将幻觉(零工具活动→保持 cursor 等待重试)与合法 noop(agent 真正检查了 memory 发现没有新内容→正常推进)分开。配套的 noop 回归测试防止矫枉过正。

touchedTopics 推导从 filesTouched 切换为 filesWritten 是一个相关的正确性改进,放在这里合理——只有确认成功的写入才应被计入"涉及的主题"。

5 个受影响的测试文件共 40 个测试全部通过。Lint 干净。无范围蔓延、无顺手重构、无过度工程。交付了承诺的内容。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

await Promise.all([projectRebuild, userRebuild]);
}

const madeGenuineProgress =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] touchedTopics.length > 0 is redundant with hasToolActivity. Since touchedTopics is derived from filesWritten (a strict subset of filesTouched — see forkedAgent.ts lines 568→582), touchedTopics.length > 0 always implies hasToolActivity is already true. The left side of the || can never be the deciding factor.

This could mislead future maintainers into thinking there are two independent paths to cursor advancement. Either simplify:

Suggested change
const madeGenuineProgress =
const madeGenuineProgress = agentResult.hasToolActivity;

Or add a comment explaining the defensive intent:

Suggested change
const madeGenuineProgress =
// touchedTopics.length > 0 implies hasToolActivity today (filesWritten ⊆ filesTouched),
// but the explicit check guards against a future planner refactor that decouples them.
const madeGenuineProgress =
agentResult.touchedTopics.length > 0 || agentResult.hasToolActivity;

— qwen3.7-max via Qwen Code /review

status: 'completed',
finalText: '',
filesTouched: ['/tmp/auto-memory/user/prefs.md'],
filesWritten: ['/tmp/auto-memory/user/prefs.md'],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] All tests in this file set filesTouched and filesWritten to identical arrays. No test exercises the scenario where they diverge (e.g., agent reads a memory file but decides not to write to it). The PR's core semantic split — filesWritten for topic classification, filesTouched for hasToolActivity — is unverified at the unit level.

Consider adding a test like:

it('derives touchedTopics from filesWritten, not filesTouched', async () => {
  vi.mocked(runForkedAgent).mockResolvedValue({
    status: 'completed',
    finalText: '',
    filesTouched: ['/tmp/auto-memory/user/prefs.md', '/tmp/auto-memory/user/readonly.md'],
    filesWritten: ['/tmp/auto-memory/user/prefs.md'],
  });
  const result = await runAutoMemoryExtractionByAgent(mockConfig, '/tmp');
  expect(result.touchedTopics).toEqual(['user']);
  expect(result.hasToolActivity).toBe(true);
});

This proves touchedTopics reflects only filesWritten while hasToolActivity reflects filesTouched.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent verification — merge reference

I independently verified this PR against a real build of the PR worktree (20a72a91f) on Linux / Node 22, driving everything through tmux + vitest. Verdict: the fix is correct, the new test genuinely catches the bug, and there are no regressions. I also wrote an extra end‑to‑end integration test that exercises the real hasToolActivity computation which the PR's own unit tests stub out — it confirms the read‑only‑noop vs. hallucination distinction on the real on‑disk cursor file.

1 · Bug reproduction — A/B in a real build

Reverting only the extract.ts cursor guard (keeping the new test and its hasToolActivity: false mock) makes the BUG #6311 test fail with exactly expected 1 to be +0 — i.e. the cursor advanced past the hallucinated message. Re‑applying the fix makes it pass. This proves the test actually guards the bug.

=== PR #6398  BUG #6311  A/B reproduction  (real vitest, worktree @ 20a72a91f) ===

>>> [BEFORE] fix reverted  (cursor advances unconditionally):
  × BUG #6311: should NOT advance cursor when agent makes zero tool calls (hallucination)
    → expected 1 to be +0 // Object.is equality
  AssertionError: expected 1 to be +0 // Object.is equality
      Tests  1 failed | 15 skipped (16)

>>> [AFTER]  PR #6398 applied  (cursor held when zero tool calls):
  ✓ src/memory/extract.test.ts (16 tests | 15 skipped)
      Tests  1 passed | 15 skipped (16)

2 · No regressions

All 5 test files the PR touches pass, and the entire packages/core/src/memory subsystem is green (30 files / 345 tests) — matching the counts claimed in the PR description.

>>> [A] the 5 test files the PR touches:
  ✓ src/memory/extractAgent.test.ts               (1 test)
  ✓ src/memory/extract.test.ts                    (16 tests)
  ✓ src/memory/extractionAgentPlanner.test.ts     (11 tests)
  ✓ src/memory/remember.test.ts                   (11 tests)
  ✓ src/memory/memoryLifecycle.integration.test.ts (1 test)
   Test Files  5 passed (5)        Tests  40 passed (40)

>>> [B] whole memory subsystem (PR footprint only):
   Test Files  30 passed (30)      Tests  345 passed (345)

3 · Real end‑to‑end integration (added coverage) 🔬

The PR's unit tests mock the whole planner and inject hasToolActivity directly, so they never exercise the real computation (filesTouched.length > 0) nor the touchedTopics ← filesWritten switch. I added an integration test that mocks only runForkedAgent — the real LLM+tools boundary, which is unchanged by this PR — and runs the real planner → real runAutoMemoryExtract → real on‑disk cursor file:

Extractor outcome filesTouched filesWritten hasToolActivity touchedTopics on‑disk processedOffset Correct?
Hallucination (0 tool calls) [] [] false [] 0 — held → retried
Read‑only noop (read_file) […] [] true [] 1 — advanced
Real write (write_file) […] […] true [user] 1 — advanced

The decisive rows are the first two: both produce touchedTopics = [], yet the cursor decision differs — exactly the ambiguity that a naive touchedTopics.length > 0 gate could not resolve and that hasToolActivity fixes. Reverting the fix flips the hallucination row to processedOffset = 1 (message lost forever).

=== REAL integration E2E: runForkedAgent(scripted) → real planner → real extract → on-disk cursor ===
>>> fix applied — 3 extractor outcomes, asserting the real cursor file:
  [HALLUCINATION] touchedTopics=[]        on-disk processedOffset=0  (history.length=1)
  [READ-ONLY NOOP] touchedTopics=[]       on-disk processedOffset=1  (history.length=1)
  [REAL WRITE]    touchedTopics=["user"]  on-disk processedOffset=1  (history.length=1)
        Tests  3 passed (3)

>>> [integration A/B] revert the fix, re-run ONLY the hallucination case:
  [HALLUCINATION] touchedTopics=[]        on-disk processedOffset=1  (history.length=1)
    → expected 1 to be +0 // Object.is equality
        Tests  1 failed | 2 skipped (3)

Why the fix is sound

hasToolActivity = result.filesTouched.length > 0, and filesTouched is populated on every TOOL_CALL whose args contain a path/file/target key (forkedAgent.tsextractFilePathsFromArgs). read_file's param is file_path, so a genuine read‑only inspection sets hasToolActivity = true, while the issue's zero‑tool‑call hallucination leaves filesTouched empty. touchedTopics now derives from filesWritten (confirmed writes), matching remember.ts. The pairing is correct.

Minor observation (non‑blocking)

hasToolActivity really means "made at least one path‑bearing tool call". The extractor also has run_shell_command in its toolset, whose args (command/directory/…) contain no path/file/target key — so a hypothetical noop performed entirely via shell would read as hasToolActivity = false and hold the cursor. That failure mode is conservative (retry, never data loss) and no worse than today, so it's fine as‑is — just noting it in case a future change wants hasToolActivity to also count shell activity.


Verified locally in a git worktree on PR HEAD 20a72a91f; source fix restored after each A/B swap. The integration test above (pr6398.integration.e2e.test.ts) is my own harness and is not part of the PR — it's included here purely as evidence. Rendered terminal screenshots of these three runs are available on request.

🇨🇳 中文版验证报告(点击展开)

✅ 独立验证 —— 供合并参考

我在 PR 分支的真实构建(worktree 20a72a91f,Linux / Node 22)上,通过 tmux + vitest 独立验证了本 PR。结论:修复正确,新增测试确实能捕获该 bug,且无回归。 我还额外写了一个端到端集成测试,去真正跑通了 PR 自带单测所 mock 掉的 hasToolActivity 计算 —— 它在真实落盘的 cursor 文件上确认了「只读 noop」与「幻觉」两种场景的区分。

1 · Bug 复现 —— 真实构建下的 A/B

仅还原 extract.ts 的 cursor 守卫(保留新测试及其 hasToolActivity: false mock),BUG #6311 测试就会以 expected 1 to be +0 失败 —— 即 cursor 越过了幻觉消息。重新应用修复后测试通过。这证明该测试确实守护了这个 bug。

2 · 无回归

PR 改动的 5 个测试文件全部通过,整个 packages/core/src/memory 子系统全绿(30 文件 / 345 测试),与 PR 描述中的数字一致。

3 · 真实端到端集成(新增覆盖)🔬

PR 自带单测把整个 planner mock 掉、直接注入 hasToolActivity,因此从未真正跑过 filesTouched.length > 0 的计算,也没跑过 touchedTopics ← filesWritten 的切换。我新增的集成测试只 mock runForkedAgent(真正的 LLM+工具边界,本 PR 未改动它),其余走真实 planner → 真实 runAutoMemoryExtract → 真实落盘 cursor 文件

提取器结果 filesTouched filesWritten hasToolActivity touchedTopics 落盘 processedOffset 正确?
幻觉(0 次工具调用) [] [] false [] 0 —— 保持,下次重试
只读 noopread_file […] [] true [] 1 —— 推进
真实写入write_file […] […] true [user] 1 —— 推进

关键在前两行:两者的 touchedTopics 都是 [],但 cursor 决策不同 —— 这正是单看 touchedTopics.length > 0 无法区分、而 hasToolActivity 能解决的歧义。还原修复后,幻觉那一行会翻转成 processedOffset = 1(消息被永久丢弃)。

修复为何成立

hasToolActivity = result.filesTouched.length > 0,而 filesTouched 会在每一次 args 含 path/file/target 键的 TOOL_CALL 上被填充(forkedAgent.tsextractFilePathsFromArgs)。read_file 的参数名是 file_path,因此真正的只读检查会令 hasToolActivity = true,而 issue 中「零工具调用」的幻觉则让 filesTouched 为空。touchedTopics 改从 filesWritten(确认写入)派生,与 remember.ts 一致。搭配正确。

一点非阻塞性观察

hasToolActivity 实质是「至少发生过一次带路径的工具调用」。提取器工具集中还有 run_shell_command,其参数(command/directory/…)不含 path/file/target 键 —— 所以若某次 noop 完全通过 shell 完成,会被判为 hasToolActivity = false 从而保持 cursor。该失败模式是保守的(重试,绝不丢数据),也不比现状更差,因此保持现状即可 —— 仅作提示,以备将来若希望 hasToolActivity 也计入 shell 活动时参考。

以上均在 PR HEAD 20a72a91fgit worktree 中真实验证;每次 A/B 切换后都恢复了源码。上文用到的集成测试(pr6398.integration.e2e.test.ts)是我自己的验证脚手架,不属于本 PR,仅作为证据附上。三次运行的终端截图可按需提供。

@wenshao
wenshao added this pull request to the merge queue Jul 7, 2026
Merged via the queue into QwenLM:main with commit 6352d97 Jul 7, 2026
53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AutoMemory cursor extract cursor advances whenever the forked agent “completes" even when its didn't work

3 participants