fix(agent): stop the send-path repair from rewriting persisted history - #80616
fix(agent): stop the send-path repair from rewriting persisted history#806160xGr1mm wants to merge 1 commit into
Conversation
`_canonicalize_api_tool_calls` promises copy-on-write in its own docstring
— "the persisted history is untouched" — and the call site repeats it:
"Operates on api_messages (the API copy) so the original conversation
history in `messages` is untouched."
The canonicalize branch keeps that promise (`tc = {**tc, "function": {...}}`).
The repair branch does not:
except Exception:
tc["function"]["arguments"] = _repair_tool_call_arguments(...)
`api_messages` is built with `msg.copy()` — a SHALLOW per-message copy — so
every `tool_calls` entry is the same dict object the persisted history
holds. Assigning into `tc["function"]` therefore writes through to the
stored turn. The sibling loop two lines above only touches `am["content"]`,
one level deep, which is why the aliasing never showed up there.
On the unrepairable path `_repair_tool_call_arguments` returns "{}", so
that write replaces the model's real arguments with an empty object in the
transcript. A stream that dies mid `write_file` loses the file content it
had already streamed — the reported symptom in NousResearch#80498, where a chapter
draft was silently reduced to `{}` and only a WARNING remained:
Unrepairable tool_call arguments for write_file — replaced with empty
object (was: {"content": "# 骨架-第25章\n> 承接...)
Mirror the canonicalize branch: build a new tool-call dict instead of
assigning into the shared one. The API copy still carries "{}" — the
repair's whole purpose is to never ship broken JSON — but the history keeps
what the model actually sent, so the transcript, session persistence and
any later retry still have it.
The in-place write was not an oversight in isolation: it predates the memo
refactor, which preserved it deliberately for byte-parity. The existing
`test_history_not_mutated` asserts exactly this invariant but restricts
itself to valid arguments, and its docstring records the gap — "(Malformed
args take the in-place repair path — pre-existing behavior)". That is why
a test file whose header already claims "the persisted history is never
mutated (copy-on-write preserved)" stayed green through the bug.
Four tests close it: history keeps the original bytes, the send copy is
still repaired, a broken call does not disturb its siblings, and repeated
sends stay lossless. On unpatched main three of them fail; the parity and
complexity tests are unaffected because the difference is only observable
when the history list is separate from the send copy — which is the shape
production uses.
Refs NousResearch#80498
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…esearch#25437) Followup to PR NousResearch#24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (NousResearch#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."<n>@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - NousResearch#81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - NousResearch#81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - NousResearch#81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - NousResearch#80688 (constrain sandbox): we don't have an outer-sandbox concept - NousResearch#80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - NousResearch#80278 (expose activeModel in plugin SDK): not our surface - NousResearch#80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new).
…ugh class The api_messages build used a shallow msg.copy(), decoupling only top-level fields. Every nested container (tool_calls entries and their function dicts, multimodal content-part lists, reasoning_details) stayed aliased to the persisted history, so ANY in-place transform on the send copy silently rewrote the stored transcript. Probed every send-path transform against that aliasing shape on main: content strip loop safe (top-level reassign) _canonicalize_api_tool_calls (repair) LEAKED <- #80616's fix _sanitize_messages_surrogates LEAKED (multimodal parts, tc ids/args, reasoning) _sanitize_messages_non_ascii LEAKED (multimodal parts) _sanitize_api_messages safe _drop_thinking_only_and_merge_users safe The retry loop already believed the copies were independent - it sanitizes messages AND api_messages separately (~L3555) - so the aliasing was accidental everywhere. Fix at the chokepoint: _clone_message_for_send clones every container (dict/list) recursively while sharing immutable leaves, so every downstream in-place transform - current and future - is safe by construction. Cost is container-count, not string-bytes: 100KB argument strings and base64 payloads are shared (measured ~0.5ms vs ~0.1ms per 1500-message build; noise next to one json round-trip). Same clone applied to the prefill-message insert (same class, same pipeline). The class-wide invariant test runs the full send-path transform pipeline over an adversarial fixture (malformed args, surrogates, non-ASCII, multimodal parts, reasoning fields) and asserts the history stays byte-identical; an AST contract pins the build-site wiring so the shallow copy can't quietly return. Both mutation-verified: reverting the clone to shallow fails 4 isolation tests, unwiring the build site fails the AST contract. 0xGr1mm's branch fix (previous commit) remains as defense in depth at the exact site the #80498 incident hit; his regression tests and the class-wide invariant give layered coverage.
|
Merged via #80963 with your commit cherry-picked and authorship preserved (rebase-merge — Your diagnosis was exactly right — and it generalized: probing every send-path transform against the shallow-copy aliasing shape showed the surrogate and non-ASCII sanitizers leaking into stored multimodal parts and reasoning fields through the same mechanism your repair-branch finding exposed. The salvage keeps your branch-level copy-on-write as defense in depth and adds a structural clone at the Thanks for the excellent find and the clean, well-tested fix! |
…esearch#25437) Followup to PR NousResearch#24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (NousResearch#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."<n>@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - NousResearch#81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - NousResearch#81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - NousResearch#81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - NousResearch#80688 (constrain sandbox): we don't have an outer-sandbox concept - NousResearch#80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - NousResearch#80278 (expose activeModel in plugin SDK): not our surface - NousResearch#80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new).
…ugh class The api_messages build used a shallow msg.copy(), decoupling only top-level fields. Every nested container (tool_calls entries and their function dicts, multimodal content-part lists, reasoning_details) stayed aliased to the persisted history, so ANY in-place transform on the send copy silently rewrote the stored transcript. Probed every send-path transform against that aliasing shape on main: content strip loop safe (top-level reassign) _canonicalize_api_tool_calls (repair) LEAKED <- NousResearch#80616's fix _sanitize_messages_surrogates LEAKED (multimodal parts, tc ids/args, reasoning) _sanitize_messages_non_ascii LEAKED (multimodal parts) _sanitize_api_messages safe _drop_thinking_only_and_merge_users safe The retry loop already believed the copies were independent - it sanitizes messages AND api_messages separately (~L3555) - so the aliasing was accidental everywhere. Fix at the chokepoint: _clone_message_for_send clones every container (dict/list) recursively while sharing immutable leaves, so every downstream in-place transform - current and future - is safe by construction. Cost is container-count, not string-bytes: 100KB argument strings and base64 payloads are shared (measured ~0.5ms vs ~0.1ms per 1500-message build; noise next to one json round-trip). Same clone applied to the prefill-message insert (same class, same pipeline). The class-wide invariant test runs the full send-path transform pipeline over an adversarial fixture (malformed args, surrogates, non-ASCII, multimodal parts, reasoning fields) and asserts the history stays byte-identical; an AST contract pins the build-site wiring so the shallow copy can't quietly return. Both mutation-verified: reverting the clone to shallow fails 4 isolation tests, unwiring the build site fails the AST contract. 0xGr1mm's branch fix (previous commit) remains as defense in depth at the exact site the NousResearch#80498 incident hit; his regression tests and the class-wide invariant give layered coverage.
…esearch#25437) Followup to PR NousResearch#24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (NousResearch#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."<n>@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - NousResearch#81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - NousResearch#81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - NousResearch#81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - NousResearch#80688 (constrain sandbox): we don't have an outer-sandbox concept - NousResearch#80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - NousResearch#80278 (expose activeModel in plugin SDK): not our surface - NousResearch#80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new).
…ugh class The api_messages build used a shallow msg.copy(), decoupling only top-level fields. Every nested container (tool_calls entries and their function dicts, multimodal content-part lists, reasoning_details) stayed aliased to the persisted history, so ANY in-place transform on the send copy silently rewrote the stored transcript. Probed every send-path transform against that aliasing shape on main: content strip loop safe (top-level reassign) _canonicalize_api_tool_calls (repair) LEAKED <- NousResearch#80616's fix _sanitize_messages_surrogates LEAKED (multimodal parts, tc ids/args, reasoning) _sanitize_messages_non_ascii LEAKED (multimodal parts) _sanitize_api_messages safe _drop_thinking_only_and_merge_users safe The retry loop already believed the copies were independent - it sanitizes messages AND api_messages separately (~L3555) - so the aliasing was accidental everywhere. Fix at the chokepoint: _clone_message_for_send clones every container (dict/list) recursively while sharing immutable leaves, so every downstream in-place transform - current and future - is safe by construction. Cost is container-count, not string-bytes: 100KB argument strings and base64 payloads are shared (measured ~0.5ms vs ~0.1ms per 1500-message build; noise next to one json round-trip). Same clone applied to the prefill-message insert (same class, same pipeline). The class-wide invariant test runs the full send-path transform pipeline over an adversarial fixture (malformed args, surrogates, non-ASCII, multimodal parts, reasoning fields) and asserts the history stays byte-identical; an AST contract pins the build-site wiring so the shallow copy can't quietly return. Both mutation-verified: reverting the clone to shallow fails 4 isolation tests, unwiring the build site fails the AST contract. 0xGr1mm's branch fix (previous commit) remains as defense in depth at the exact site the NousResearch#80498 incident hit; his regression tests and the class-wide invariant give layered coverage.
…esearch#25437) Followup to PR NousResearch#24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (NousResearch#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."<n>@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - NousResearch#81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - NousResearch#81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - NousResearch#81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - NousResearch#80688 (constrain sandbox): we don't have an outer-sandbox concept - NousResearch#80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - NousResearch#80278 (expose activeModel in plugin SDK): not our surface - NousResearch#80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new).
…esearch#25437) Followup to PR NousResearch#24182 — caught when scanning OpenClaw for recent codex fixes we hadn't considered. OpenClaw learned the hard way (NousResearch#80815) that migrating plugins which codex itself reports as unavailable produces config that fails at activation time. Our /codex-runtime codex_app_server enable path queries codex's plugin/list and migrates everything where installed=true. We were trusting codex's installation state and ignoring its availability field. So a plugin that's installed=true but availability=UNAVAILABLE (broken local install) or REQUIRES_AUTH (OAuth expired or never completed) would get an [plugins."<n>@openai-curated"] entry in ~/.codex/config.toml — and the user's first codex turn after enabling the runtime would fail because codex refuses to activate it. Fix: filter on availability in _query_codex_plugins(). Only emit plugins where availability is empty (older codex versions without the field — preserve backward compat) or explicitly AVAILABLE. Tests: test_plugin_discovery_skips_unavailable_plugins — verifies 4 cases: - good-plugin (installed=True, availability=AVAILABLE) → migrated - broken-plugin (installed=True, availability=UNAVAILABLE) → skipped - auth-pending (installed=True, availability=REQUIRES_AUTH) → skipped - legacy-plugin (installed=True, no availability field) → migrated (older codex versions; preserve backward compat) Docs: Added bullet to 'What's NOT migrated' list in the docs page calling out the availability filter and why. Other OpenClaw codex PRs I reviewed but did NOT apply (with reasoning): - NousResearch#81591 (load Codex for selectable models): we resolve runtime per-call already, no startup-time gating to fix - NousResearch#81510 (cron compatibility): we documented cron as untested; their fix is for OpenClaw-specific cron orchestration shape - NousResearch#81223 (rotate incompatible context-engine threads): we don't have a Lossless context engine equivalent - NousResearch#80688 (constrain sandbox): we don't have an outer-sandbox concept - NousResearch#80616 (release on turn_aborted): we already handle status= interrupted in turn/completed correctly - NousResearch#80278 (expose activeModel in plugin SDK): not our surface - NousResearch#80792 (default destructive_actions on): we don't expose that knob 56 codex-runtime migration tests still green (+1 new).
…ugh class The api_messages build used a shallow msg.copy(), decoupling only top-level fields. Every nested container (tool_calls entries and their function dicts, multimodal content-part lists, reasoning_details) stayed aliased to the persisted history, so ANY in-place transform on the send copy silently rewrote the stored transcript. Probed every send-path transform against that aliasing shape on main: content strip loop safe (top-level reassign) _canonicalize_api_tool_calls (repair) LEAKED <- NousResearch#80616's fix _sanitize_messages_surrogates LEAKED (multimodal parts, tc ids/args, reasoning) _sanitize_messages_non_ascii LEAKED (multimodal parts) _sanitize_api_messages safe _drop_thinking_only_and_merge_users safe The retry loop already believed the copies were independent - it sanitizes messages AND api_messages separately (~L3555) - so the aliasing was accidental everywhere. Fix at the chokepoint: _clone_message_for_send clones every container (dict/list) recursively while sharing immutable leaves, so every downstream in-place transform - current and future - is safe by construction. Cost is container-count, not string-bytes: 100KB argument strings and base64 payloads are shared (measured ~0.5ms vs ~0.1ms per 1500-message build; noise next to one json round-trip). Same clone applied to the prefill-message insert (same class, same pipeline). The class-wide invariant test runs the full send-path transform pipeline over an adversarial fixture (malformed args, surrogates, non-ASCII, multimodal parts, reasoning fields) and asserts the history stays byte-identical; an AST contract pins the build-site wiring so the shallow copy can't quietly return. Both mutation-verified: reverting the clone to shallow fails 4 isolation tests, unwiring the build site fails the AST contract. 0xGr1mm's branch fix (previous commit) remains as defense in depth at the exact site the NousResearch#80498 incident hit; his regression tests and the class-wide invariant give layered coverage.
What does this PR do?
_canonicalize_api_tool_callspromises copy-on-write in its own docstring — "the persisted history is untouched" — and the call site repeats it: "Operates on api_messages (the API copy) so the original conversation history inmessagesis untouched."The canonicalize branch keeps that promise. The repair branch does not:
api_messagesis built withmsg.copy()— a shallow per-message copy — so everytool_callsentry is the same dict object the persisted history holds. Assigning intotc["function"]therefore rewrites the stored turn. The sibling loop two lines above only touchesam["content"], one level deep, which is why the aliasing never surfaced there.On the unrepairable path
_repair_tool_call_argumentsreturns"{}", so that write replaces the model's real arguments with an empty object in the transcript.Why it matters
This is the mechanism behind the silent data loss reported in #80498. A stream that dies mid
write_fileloses the file content it had already streamed, leaving only a warning:Reproduced against the shipped function with the send path's own aliasing shape:
The fix
Mirror the canonicalize branch: build a new tool-call dict instead of assigning into the shared one. The API copy still carries
"{}"— never shipping broken JSON is the repair's entire purpose — but the history keeps what the model actually sent, so the transcript, session persistence and any later retry still have it.Why this survived so long
The in-place write is older than the memo refactor, which preserved it deliberately for byte-parity. The existing
test_history_not_mutatedasserts exactly this invariant, but restricts itself to valid arguments, and its docstring records the gap:So a test file whose header already claims "the persisted history is never mutated (copy-on-write preserved)" stayed green straight through the bug.
Related Issue
Refs #80498 (P1,
type/bug,comp/agent,tool/file) — this PR fixes the history-corruption half of that report. Searched open and merged PRs first, per CONTRIBUTING's search-first section:Nothing addresses this. The nearest open PRs are all on the repair side and touch different code: #16505 repairs args on the invalid-JSON recovery write site in
run_agent.py, #79333 recovers wrapped args inmessage_sanitization.py, #74149 only widens that module's log line. #42314 (merged, P1) added the mid-tool-call-drop detection quoted in the issue; it is upstream of this and unchanged here.Type of Change
Bug fix (non-breaking).
Changes Made
agent/conversation_loop.py— the repair branch of_canonicalize_api_tool_callsis now copy-on-write, matching the canonicalize branch; comment records why the aliasing exists.tests/agent/test_canon_args_memo_parity.py— 4 tests in a new class covering the repair path the existing history test deliberately excluded.How to Test
13 passed.
Reverting only
agent/conversation_loop.pyand rerunning:with
The fourth new test (
test_send_copy_is_still_repaired) passes both before and after by design — it pins that the API copy is still repaired, so this change is only about the aliasing.The 9 pre-existing tests, including the byte-parity and complexity proofs, pass unchanged: the difference is only observable when the history list is separate from the send copy, which is the shape production uses and the old parity harness did not.
tests/agent/ + tests/run_agent/, this branch vsmain, same environment, run serially:Failing sets are identical — zero added, zero removed. Those 171 are pre-existing in a non-hermetic single-process run; CI's per-file isolation via
run_tests_parallel.pyis the supported path.Checklist
Code
fix(agent): …)Documentation & Housekeeping
cli-config.yaml.example— N/A, no config keysCONTRIBUTING.md/AGENTS.md— N/ANotes for the reviewer
Deliberately scoped to the aliasing. #80498 also asks that a truncated call not be executed with empty arguments and that the model be told it failed; that is a separate decision in the stream/tool-dispatch path (
_build_partial_stream_stubalready drops the calls whenfinish_reason is None, so the remaining execution path is narrower than the report implies). I did not fold it in — it changes turn control flow, where this change only stops the transcript from being overwritten. Happy to follow up if you want that half too.One consequence worth naming: broken arguments now stay broken in history, so each send re-runs the failed
json.loadsand repair for that message instead of repairing it once. The memo only caches successes, so this was already true for every malformed argument that repaired to something other than"{}"— it is one failed parse per affected message per iteration, against a transcript that no longer loses data.