Skip to content

fix(context): orphaned tool_result HTTP 400 — three-layer defense (ISSUE #171) - #174

Merged
ULookup merged 8 commits into
mainfrom
fix/issue-171-orphaned-tool-result
Jun 29, 2026
Merged

fix(context): orphaned tool_result HTTP 400 — three-layer defense (ISSUE #171)#174
ULookup merged 8 commits into
mainfrom
fix/issue-171-orphaned-tool-result

Conversation

@ULookup

@ULookup ULookup commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #171 — Anthropic API HTTP 400 caused by orphaned tool_result blocks lacking matching tool_use blocks.

Three-layer defense, each independently revertable:

  • Layer 3 (safety net, commit 6a31f9b): ContextSerializer::sanitize_orphans() drops orphan tool_result at head and orphan tool_use at tail before serialization. Pure function, try/catch-wrapped so it can never crash the AgentLoop. Applies to both OpenAI and Anthropic formats.
  • Layer 1 (source fix, commits 001da48 + b1afb05): MemoryStore::recent_history() slices on user-message boundaries instead of naive max_turns*2. Adds adjust_for_orphan_tools helper that expands the window backward to cover a parent assistant (when within max_turns*2+4) or drops leading orphan tool messages (when parent is too far).
  • Layer 2 (trim fix, commits 3ea265d + a16aeb0): ContextPipeline::planned_assemble() hard trim deletes whole rounds (user-led boundaries) instead of per-message erase. Also fixes a latent bug: serialize() moved from after hard trim to before it — the old position made hard trim a no-op on the actual payload.

Commit cddc570 closes spec gaps: adds DoesNotDeleteSystemMessages test, wraps sanitize_orphans in try/catch per spec line 318, documents the serialize() move in the spec.

Design: docs/superpowers/specs/2026-06-22-issue-171-orphaned-tool-result-design.md
Plan: docs/superpowers/plans/2026-06-22-issue-171-orphaned-tool-result.md

Test Plan

  • merak-context-serializer-test — 5 cases (orphan head/tail Anthropic, paired preserved, multiple orphans, OpenAI head)
  • merak-memory-history-test — 6 cases (no tools, tool boundary, well-formed, far-parent drop, empty, no-user fallback expand)
  • merak-context-pipeline-test — 4 cases (round boundaries, preserve 1 round, end-to-end ISSUE LLM API 400: orphaned tool_result blocks in Anthropic request — tool_use_id mismatch #171 repro, system message preserved)
  • merak-context-test — existing context tests still pass
  • merak-agent-loop-test — 26/26 existing loop tests still pass
  • Manual smoke test: run agent with tool-heavy conversation that previously triggered HTTP 400, confirm error no longer occurs

ULookup added 8 commits June 22, 2026 12:07
Three-layer defense for tool_use/tool_result pairing:
- Layer 1: MemoryStore::recent_history round-aware window + orphan expand/drop
- Layer 2: ContextPipeline hard trim round-aware deletion
- Layer 3: ContextSerializer sanitize_orphans safety net

References mainstream Agent implementations (Claude Code, Codex, OpenCode)
and includes three-layer regression test plan.
Three-task TDD plan:
- Task 1: Layer 3 safety net (ContextSerializer sanitize_orphans)
- Task 2: Layer 1 source fix (MemoryStore recent_history round-aware)
- Task 3: Layer 2 hard trim round-aware deletion
- Task 4: Final verification

Each task: failing test → CMake registration → implement → rebuild →
regression check → commit.
Drops orphan tool_result at head and orphan tool_use at tail before
serialization. Prevents Anthropic API HTTP 400 errors when upstream
windowing or trimming produces unpaired tool messages.

Layer 3 of ISSUE #171 fix.
…esults

Rewrite recent_history to slice on user-message boundaries instead of
naive max_turns*2 estimate. Adds adjust_for_orphan_tools helper that
expands the window to cover a parent assistant when nearby, or drops
leading orphan tool messages when the parent is too far.

Layer 1 of ISSUE #171 fix.
Previous Test 4 and Test 6 passed trivially because the window never
started on a tool message. Rewrote scenarios to force start onto a
tool_result, triggering the drop path (Test 4) and expand path (Test 6).
…sult pairing

Replace per-message erase in hard trim with whole-round deletion (user-led
boundaries). Prevents orphaned tool_result blocks when token-budget trim
removes an assistant message containing tool_use. Re-scans round starts
each iteration to avoid index drift.

Layer 2 of ISSUE #171 fix.
…riggered

- Per-message token truncation in hard trim matches initial computation,
  eliminates negative tokens_after artifact in logs.
- Add pipeline.stats().hard_trims > 0 assertion to all 3 test cases so
  they verify hard trim actually fired (not just that output is clean).
- Strengthen Test 2 to also assert front message is a user (round boundary).
…/catch, spec update

- Add Test 4: DoesNotDeleteSystemMessages to test_pipeline_hard_trim.cpp
  (spec-required test case, was missing).
- Wrap sanitize_orphans in try/catch per spec line 318: safety net must
  not crash AgentLoop on unexpected exceptions.
- Document the serialize() move in spec: Layer 2 fix moves serialize()
  after hard trim to fix latent no-op bug where hard trim mutated
  bound.provider_messages after payload was already built.
@ULookup
ULookup merged commit 06f4b58 into main Jun 29, 2026
1 of 5 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.

LLM API 400: orphaned tool_result blocks in Anthropic request — tool_use_id mismatch

1 participant