Tool-pair atomicity: the killer feature¶
Multi-turn agents emit tool_use blocks (e.g. "call get_weather") followed by tool_result blocks (e.g. "rainy, 8 °C"). If a context-window packer evicts only one half of that pair, the next API call is rejected with a 400.
Most existing trimmers — including LangChain's trim_messages — don't enforce this. convopack does, for every strategy.
This notebook demonstrates and verifies the guarantee.
from convopack import (
FirstFit,
Importance,
Message,
Packer,
Recency,
TextBlock,
ToolResultBlock,
ToolUseBlock,
)
from convopack._pairs import validate_pairs
1. A history with several tool exchanges¶
An agent asked about the weather in three cities, with one tool call per city:
def tool_pair(call_id: str, city: str, result: str) -> list[Message]:
return [
Message(
role="assistant",
content=[ToolUseBlock(id=call_id, name="weather", input={"city": city})],
),
Message(
role="tool",
content=[ToolResultBlock(tool_use_id=call_id, content=result)],
),
]
history = [
Message(role="system", content=[TextBlock(text="You are a weather assistant.")]),
Message(role="user", content=[TextBlock(text="Oslo?")]),
*tool_pair("t1", "oslo", "rainy, 8\u00b0C"),
Message(role="assistant", content=[TextBlock(text="It's rainy and 8\u00b0C in Oslo.")]),
Message(role="user", content=[TextBlock(text="Bergen?")]),
*tool_pair("t2", "bergen", "cloudy, 10\u00b0C"),
Message(role="assistant", content=[TextBlock(text="Bergen is cloudy, 10\u00b0C.")]),
Message(role="user", content=[TextBlock(text="Trondheim?")]),
*tool_pair("t3", "trondheim", "snowing, -2\u00b0C"),
Message(role="assistant", content=[TextBlock(text="Trondheim is snowing, -2\u00b0C.")]),
]
len(history)
2. Pack with a tight budget and verify pairs¶
We deliberately pick a budget that forces eviction. Then we check the kept output for dangling tool_use IDs (ones without a matching tool_result).
validate_pairs(kept) returns the list of dangling IDs. An empty list is the success state.
packer = Packer(budget=60, tokenizer="approx", strategy=Recency(), pin=("system",))
result = packer.pack(history)
print(f"kept {len(result.kept)} / dropped {len(result.dropped)}")
print(f"dangling tool IDs in kept: {validate_pairs(result.kept)}")
print()
for m in result.kept:
kind = "text" if not (m.has_tool_use() or m.has_tool_result()) else (
"tool_use" if m.has_tool_use() else "tool_result"
)
print(f" [{m.role:>10}] ({kind}) {m.text()[:40]}")
Even though some older exchanges were dropped, the pairs that survived are intact. You can take this output straight to your provider's messages.create and it will be accepted.
3. The same guarantee holds for every strategy¶
Pair atomicity is enforced at the chunking layer, before any strategy sees the data. So it holds regardless of which strategy you pick:
for strategy in (Recency(), FirstFit(), Importance()):
p = Packer(budget=60, tokenizer="approx", strategy=strategy, pin=("system",))
r = p.pack(history)
dangling = validate_pairs(r.kept)
print(f"{strategy.name:<10} kept={len(r.kept):>2} dangling={dangling}")
4. What happens under random budgets (property check)¶
Let's spam 200 random budgets and assert the invariant every time:
import random
random.seed(0)
failures = 0
for _ in range(200):
budget = random.randint(10, 2000)
p = Packer(budget=budget, tokenizer="approx", strategy=Recency(), pin=("system",))
r = p.pack(history)
if validate_pairs(r.kept):
failures += 1
print(f"failures across 200 random budgets: {failures}")
Zero. The library is property-tested in CI with Hypothesis to keep this guarantee from regressing.
Why this matters¶
If you skip a packer that enforces this:
- Anthropic responds with
400: tool_result without preceding tool_use. - OpenAI responds with
400: 'tool_calls' must be followed by corresponding 'tool' message. - Your retry logic loops, your user sees a hang, your bill grows.
convopack makes this class of bug impossible by construction.