Skip to content

fix(trajectory): track nested edits and safe diagnostic lineage - #85

Merged
zhanghanduo merged 3 commits into
mainfrom
codex/fix-trajectory-cache-and-annotations
Oct 29, 2026
Merged

zhanghanduo merged 3 commits into
mainfrom
codex/fix-trajectory-cache-and-annotations

Conversation

@zhanghanduo

@zhanghanduo zhanghanduo commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #84 fixing stale trajectory snapshots and incorrect diagnostic attribution after history rewrites.

Routine snapshots reuse the spool prefix by reference. A compactor run, context-compacted/compaction notice, or forced completion/cancellation snapshot also compares redacted rendered lines to detect nested in-place edits. This avoids serializing raw history and hashing inline image base64 on every flush. Unannounced nested edits by arbitrary observers are reflected at the next deep check or final snapshot; replacing a message or top-level field is detected during routine snapshots.

Store diagnostic entries in a dictionary keyed by message identity. Preserve direct identity bindings first; transfer diagnostics to a replacement only when it shares mutable content/tool-call containers or blocks with exactly one vanished assistant and no other replacement claims that source. Scalar identities, including cached single-character JSON strings and reasoning strings, are excluded. Deduplicate sources using id(entry) dictionaries without comparing message contents, so cyclic metadata cannot cause recursive comparisons before rendering.

Ambiguous, deep-copied and scalar-only copied assistants retain their message content without guessed JSON annotations; original diagnostics remain available in JSONL when enabled. Unique mutable shallow-copy lineage and real signed-thinking resets retain diagnostics. Unencodable host metadata degrades to a message with trace_error rather than freezing all later snapshots. Snapshot work remains synchronous but coalesced; observer failures are logged and isolated by the loop.

Regression coverage includes nested text/argument/image-metadata/usage edits, copied and resumed histories, compactor/rollback deep-check scheduling, unregistered replacement replies, cached scalar strings, cyclic metadata during source deduplication, and JSONL diagnostic preservation. Documentation and the independent release fragment describe the final behavior.

Validation:

  • Full suite: 2403 passed, 1 skipped.
  • Related trajectory/image tests: 119 passed.
  • Ruff, strict Pyright, unconsumed-field and release-fragment checks passed.
  • uv build: source distribution and wheel built.
  • Independent review found no new blocking issues.

@zhanghanduo

Copy link
Copy Markdown
Collaborator Author

Code review

PR 能修好嵌套原地编辑和"文本相同导致继承别人诊断信息"这两个问题,但引入了几个性能和健壮性问题。下面按严重程度列出,均位于 agent_core/components/observers/trajectory.py。

性能

  1. 每次快照都把整段 history 序列化并哈希一遍(_sync_history 中的 _signature)。spool 前缀比较从身份比较改成了对 (message, details) 做 sha256。observer 是 critical=True,在事件循环上被同步 await,所以每次到期快照都要把全部 history 编码一遍,整个运行累计 O(n²)。
  2. 哈希的是未脱敏的原始消息。_render 会通过 redacted_for_trace 去掉内联图片的 base64,但摘要仍然对完整 base64 编码和哈希(每张 1080p 截图约 137 KB)。新消息还会被序列化两次。
  3. on_llm_response 每轮都线性扫描 _response_metadata。这个列表现在包含 history 里所有 assistant,整个运行变成 O(n²)。

诊断信息归属

  1. 身份绑定成功的条目,fingerprint 从不刷新。消息先被原地编辑(如本 PR 测试里的 EditBlock),之后 thinking reset 再拷贝它时,旧 fingerprint 匹配不上拷贝,usage/finish_reason 会悄悄丢失。
  2. 跨快照的歧义判断不完整。A1 的拷贝被移除,同一个 coalescing 窗口里又追加了一个文本相同、内容新生成的 assistant A3。这时 A1 的条目是唯一 source,A3 是唯一 target,A3 就继承了 A1 的诊断信息。

健壮性

  1. 新增的序列化路径会遇到无法编码的值(循环引用、非字符串键)。critical observer 的异常会被 _handle_observer_error 记录下来,不会中断 loop,但之后每次快照都失败,JSON 就停在最后一次成功的状态。
  2. 非 JSON 对象退化为 str() 之后,摘要可能不稳定(__str__ 里含时间戳),导致 spool 反复重写;也可能对内部变化不敏感。

设计

  1. 用"内容 fingerprint 加唯一性"去猜拷贝的来源,本身就容易误判。浅拷贝会共享值对象(content 字符串、tool_calls 列表、content block),这是比文本相等更可靠的来源信号。

修复已推送到本分支,见下一条提交:

  • 日常快照恢复为按引用比较前缀,不做序列化。嵌套编辑的检测改为比较脱敏后的渲染行的摘要(不会哈希 base64),只在以下情况整体比较一次:compactor 运行过(_compaction_seq 推进;原地编辑时 loop 不会发出 on_context_compacted)、收到 on_context_compacted/on_compaction、以及结束或取消时的强制快照。(1, 2, 7)
  • _response_metadata 改为 dict[id, entry]。(3)
  • 去掉 fingerprint,改为按来源关系绑定:拷贝必须和恰好一个已消失的 assistant 共享内容对象,且那个 assistant 没有被其他拷贝认领。文本相等永远不会转移诊断信息,深拷贝的轮次则把诊断信息留在 JSONL 里。(4, 5, 8)
  • 遇到无法编码的消息时保留它的位置和 tool_call_id,附加 trace_error,不再让整个快照失败。(6)
  • 新增 5 个回归测试(其中 4 个在原提交上失败),并同步更新了文档和 changelog。全量测试:2399 passed, 1 skipped;ruff/pyright 无报错。

zhanghanduo and others added 2 commits October 29, 2026 23:04
- Compare the spool prefix by reference on routine snapshots. Detect nested
  in-place edits by digesting redacted rendered lines, only after a compactor
  run, a context-compacted/compaction notice, or on forced snapshots, so raw
  history and inline base64 are no longer serialized on every flush.
- Key response metadata by message id instead of scanning a list per turn.
- Replace content fingerprints with copy lineage: a replacement inherits
  diagnostics only when it shares content objects with exactly one vanished
  assistant, so equal text never transfers another turn's details and
  in-place edits before a copy no longer drop them.
- Keep unencodable messages (cycles, non-string keys) in the snapshot with a
  trace_error instead of failing every later flush.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@zhanghanduo zhanghanduo changed the title fix(trajectory): validate cached content and disambiguate diagnostics fix(trajectory): track nested edits and safe diagnostic lineage Oct 29, 2026
@zhanghanduo
zhanghanduo merged commit cf25532 into main Oct 29, 2026
5 checks passed
@zhanghanduo
zhanghanduo deleted the codex/fix-trajectory-cache-and-annotations branch October 29, 2026 15:17
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.

1 participant