turnsafe
Never send a broken chat history to an LLM again.
Validate, repair and trim OpenAI and Anthropic message lists, without ever separating a tool call from its result.
Did one of these errors bring you here?
anthropic.BadRequestError: messages.4: `tool_use` ids were found without `tool_result` blocks
immediately after: toolu_01A... Each `tool_use` block must have a corresponding `tool_result`
block in the next message.
openai.BadRequestError: Invalid parameter: messages with role 'tool' must be a response to a
preceeding message with 'tool_calls'.
openai.BadRequestError: An assistant message with 'tool_calls' must be followed by tool
messages responding to each 'tool_call_id'. The following tool_call_ids did not have
response messages: call_abc123
Each one means a tool call got separated from its result. Common causes:
- You trimmed the history to fit the context window (
messages[-20:]) and the cut landed in the middle of a tool call. - You saved the conversation and reloaded it, and something got lost or reordered.
- The agent crashed or was cancelled while a tool was running.
- The user sent a message while tools were still running.
After that, every later request in the conversation fails until someone fixes the history.
turnsafe fixes the history before you send it. It works on the plain messages list you
already pass to the OpenAI and Anthropic SDKs. It has no dependencies and needs no framework or
network access.
Contents
- Install
- Quickstart
- Try it without an API key
- How it works
- API
- Anthropic thinking blocks
- How it compares
- FAQ
- Status and roadmap
- Contributing
Install
pip install turnsafe
It needs Python 3.10 or newer and has zero runtime dependencies.
Quickstart
Add one line before each API call in your agent loop:
import anthropic
import turnsafe
client = anthropic.Anthropic()
while True:
request = turnsafe.fit(turnsafe.repair(messages), max_tokens=150_000)
response = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools, messages=request
)
...
repair()heals a broken history.fit()trims whole turns so the history fits your budget.
They work the same way with openai.OpenAI().chat.completions.create(...). The provider is
detected from the message format, or you can pass provider="openai" or "anthropic".
For complete runnable agents that save and reload their history, see
examples/anthropic_agent.py
and examples/openai_agent.py.
Try it without an API key
git clone https://github.com/Yasir-Khan-7/turnsafe && cd turnsafe
pip install -e . && python examples/offline_demo.py
=== Anthropic: agent crashed between two parallel tool calls ===
anthropic history has 1 error(s), 0 warning(s):
[error] messages[1] unanswered_tool_call: tool call 'toolu_2' has no tool result in the next user message
fix: messages[1] added_placeholder: no result for 'toolu_2'
after repair: anthropic history is valid.
=== OpenAI: a user message landed between a call and its result ===
openai history has 1 error(s), 0 warning(s):
[error] messages[3] misplaced_tool_result: result for tool call 'call_7' must be in the tool messages directly after the call
fix: messages[1] moved_result: moved result for 'call_7' from messages[3]
after repair: openai history is valid.
=== fit() to 1,000 tokens ===
kept 24 of 80 messages (~977 tokens), dropped 14 whole turns; valid: True
How it works
user ─┐
│ turn 1 fit() drops whole turns, oldest first.
asst │ (tool call) ─┐ A tool call and its results are always in the
tool │ (result) ◄──┘ same turn, so they leave together or stay together.
asst ─┘
user ─┐
│ turn 2 repair() pairs every call with its result and fixes
asst │ (tool call) ─┐ anything the API would reject: missing results,
user │ "hurry!" │ orphaned results, misplaced results, duplicates.
tool │ (result) ◄──┘ ← misplaced: repair() moves it back next to its call
asst ─┘
Every rule turnsafe checks comes from the provider's documentation, or from the exact API error
text when the docs are silent. All sources are listed in
docs/rules.md.
The core promises are checked by property-based tests that run thousands of randomly damaged histories on every change:
repair()output always passesvalidate().- A valid history comes back unchanged.
repair()never mutates its input.fit()never splits a tool call from its result.
API
validate(messages, provider="auto") -> Report
validate() tells you what the API would reject, without changing anything.
report = turnsafe.validate(messages)
if not report: # falsy when there are errors
print(report.explain())
report.raise_for_errors() # or fail fast, for example in tests or CI
Each Violation has:
- a stable
code, such as"orphan_tool_result" - the message
index - the
tool_call_id - a
severity, either"error"or"warning"
repair(messages, provider="auto", *, on_missing="placeholder", placeholder=..., thinking="keep")
repair() returns a fixed copy of the history. Use repair_detailed() to also get a Fix for
every change, which is useful for logging.
messages, fixes = turnsafe.repair_detailed(messages)
for fix in fixes:
log.warning("turnsafe: %s", fix) # messages[1] added_placeholder: no result for 'toolu_2'
| Problem | What repair() does |
|---|---|
| A tool result whose call is gone, for example trimmed away | Drops it and keeps any other content in the same message. |
| A tool call with no result, for example after an interrupted run | Adds an error result saying the tool didn't run. With on_missing="strip" it removes the call instead. |
| A result in the wrong place, for example a user message got in between | Moves it back right after its call. |
| A second result for the same call | Keeps the first. |
Anthropic: text before the tool_result blocks |
Moves the results first. |
Anthropic: the same tool_use id used twice in the history |
Renames the later call and its result. |
| Anthropic: an empty user message, or a blank text block | Removes it. |
repair() also guarantees:
- It never mutates your list.
- A valid history comes back unchanged.
- The output passes
validate(). The one exception is an Anthropicsystemmessage placed first, which you have to move to thesystemparameter yourself. - By default it never rewrites an assistant message, so Anthropic thinking blocks and their signatures survive.
fit(messages, max_tokens, provider="auto", *, counter=approx_tokens, keep_system=True, keep_first_turn=False, thinking="keep")
fit() drops the oldest whole turns until the history fits in max_tokens.
result = turnsafe.fit_detailed(messages, max_tokens=100_000, keep_first_turn=True)
print(result.tokens, result.dropped_turns)
keep_systemkeeps leading system and developer messages.keep_first_turnkeeps the first turn, which is usually the original task.- If even the latest turn doesn't fit,
fit()raisesContextBudgetErrorinstead of returning a broken history. - If the input breaks the rules, it raises
InvalidHistoryError, so callrepair()first. max_tokenscovers the messages only. Leave room for tools, the system prompt and the reply.- The default counter is an offline estimate: about 4 characters per token, with images counted
by size rather than by their base64 length. Pass
counter=for exact numbers, for example withtiktokenor Anthropic's token-counting endpoint.
Rules checked
| Code | Providers | Meaning |
|---|---|---|
unanswered_tool_call |
both | A tool call has no result |
orphan_tool_result |
both | A result answers no call in the preceding assistant turn |
misplaced_tool_result |
both | The result exists but isn't directly after its call |
duplicate_tool_result |
both | A call has more than one result |
duplicate_tool_call_id |
both | A tool call id is repeated. For Anthropic, this means anywhere in the history. |
tool_result_not_first |
Anthropic | Text comes before the tool_result blocks in the user turn |
system_in_messages |
Anthropic | An error when first. Elsewhere a warning, because only Opus 4.8+, Opus 5, Fable and Mythos accept it. |
empty_content |
Anthropic | An empty user message, or a blank text block outside a tool_result |
first_message_not_user |
Anthropic | Warning: Bedrock and some deployments require a user message first |
The Anthropic API merges consecutive messages with the same role, and turnsafe does the same. Results split over several user messages in a row, or calls split over several assistant messages, are valid.
Anthropic thinking blocks
By default, turnsafe never touches thinking or redacted_thinking blocks.
On Claude Fable 5.1 and Opus 5.5, a replayed thinking block may be valid only while every
message before it is unchanged. This is enforced by default for accounts created on or after
2026-08-31. Trimming the front of the history then makes the API reject later thinking blocks
with Invalid `signature` in `thinking` block. There are two fixes:
- Set
thinking.block_binding.prefix_mismatch_behaviorto"drop_block"on the request. That's the field the API error names, and the API then drops the stale blocks itself. - Or pass
thinking="strip_after_change"tofit()andrepair(). turnsafe then removes thinking blocks after the first change, which is the documented recovery.
Don't strip on other models. They expect the latest turn's thinking to be replayed. See the details.
How it compares
| turnsafe | litellm modify_params |
LangChain trim_messages |
pydantic-ai history repair | |
|---|---|---|---|---|
| Works on plain SDK dicts, with no framework | ✅ | ❌ inside litellm.completion() |
❌ LangChain messages | ❌ pydantic-ai messages |
| OpenAI and Anthropic formats | ✅ | Anthropic path only | ✅ | ✅ |
| Reports problems without changing anything | ✅ validate() |
❌ | ❌ | ❌ |
| Repairs broken pairs | ✅ | ✅ | ❌ | ✅ |
| Trims to a token budget | ✅ | ❌ | ✅ | ❌ |
| Trimming can't orphan a tool call | ✅ property-tested | – | ❌ #33245 | – |
| Tells you what it changed | ✅ | ❌ | ❌ | ❌ |
If your framework already handles this for you, keep using it. turnsafe is for agent loops
written directly on the provider SDKs, for histories you store and reload yourself, and for
anyone who wants to check a history before sending it. For how the problem was chosen and what
we learned from existing tools, see
docs/research.md.
FAQ
Does it call any API or send my data anywhere? No. It's pure Python working on your list in memory.
Can I pass SDK objects instead of dicts?
Yes. Messages and content blocks that have model_dump() are accepted, for example
response.choices[0].message or response.content. The output is always plain dicts.
What if a tool call id isn't a string?
turnsafe raises a ValueError naming the position, for example
messages[3].tool_calls[0].id must be a non-empty string. A call without a usable id can't be
matched to its result, so it isn't silently ignored.
What does the placeholder result say?
It says the tool call wasn't executed or its result was lost, and that the model shouldn't
assume it succeeded. Change it with placeholder="...".
My history ends with an assistant tool call because I'm about to run the tools. Is that
an error?
validate() reports it, because the API would reject it if you sent it. Don't repair()
a history while you're about to add the real results yourself.
Should I use this with server-side compaction? Yes, they work together. Anthropic context editing and OpenAI Responses compaction make the context smaller, and turnsafe makes sure what you send is valid.
Status and roadmap
- v0.1: OpenAI Chat Completions and Anthropic Messages.
- Planned:
- OpenAI Responses API items
- Gemini
contents - an optional LangChain adapter
- Legacy OpenAI
function_callmessages aren't checked.
The version follows Semantic Versioning. Violation codes and fix actions are public API. Changes are listed in the CHANGELOG.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md to get set up. If you work with an AI coding agent, point it at AGENT.md. To report a security issue, see SECURITY.md.
Found a provider rule turnsafe gets wrong? Open an issue with the exact API error text. That's the most valuable report you can send.
License
MIT © Yasir Khan
Metadata
Release files for turnsafe 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| turnsafe-0.1.0.tar.gz | 48.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| turnsafe-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 76.8 kB
Release files / turnsafe-0.1.0.tar.gz
| Download URL | turnsafe-0.1.0.tar.gz |
|---|---|
| Size | 48.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bef605707fd6884df1b6919294f8d2e318f0868521761cffa4513edfe2746b41
|
|
BLAKE2b-256 checksum How to use checksums |
936d697337d5315868bf757fcb9c256968dac81cb5455fbd5b79252821109aa1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / turnsafe-0.1.0-py3-none-any.whl
| Download URL | turnsafe-0.1.0-py3-none-any.whl |
|---|---|
| Size | 28.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
823ef636bed724347693c4c218b41c3b60a9103ee7952d2ddf74de1947fe7bf8
|
|
BLAKE2b-256 checksum How to use checksums |
3cb310fc86d452fdb72ff1787eb1f61619c650cae98f2ef10791d65e3f5735e5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logSecurity for turnsafe 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for turnsafe-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.