#2645·instructor

Public `retry_sync`/`retry_async`/`handle_reask_kwargs` mutate the caller's `messages` list during validation retries

Author: betacatslingCreated Sep 14, 2026Updated Sep 14, 2026

Actual behavior

Calling instructor.core.retry_sync / retry_async (or the v2 retry_sync_v2 / retry_async_v2 they wrap) directly with a caller-owned kwargs dict permanently appends reask messages to the caller's messages list whenever a validation retry happens. The same applies to instructor.processing.handle_reask_kwargs, whose docstring says it copies kwargs "to avoid modifying the original" — it only shallow-copies the dict, and the provider reask handler then extends kwargs["messages"] in place.

After a single retried call, caller_messages contains the original user message plus the assistant tool-call message and the tool error message:

python
from pydantic import BaseModel, ValidationError
from instructor import Mode, Provider
from instructor.core import retry_sync
from openai.types.chat import ChatCompletion
from openai.types.chat.chat_completion import Choice, ChatCompletionMessage
from openai.types.chat.chat_completion_message_tool_call import (
    ChatCompletionMessageToolCall, Function,
)

class Answer(BaseModel):
    name: str
    age: int

def tool_resp(args: str) -> ChatCompletion:
    return ChatCompletion(
        id="x", created=0, model="m", object="chat.completion",
        choices=[Choice(index=0, finish_reason="tool_calls", message=ChatCompletionMessage(
            role="assistant", content="",
            tool_calls=[ChatCompletionMessageToolCall(
                id="c1", type="function",
                function=Function(name="Answer", arguments=args))]))],
    )

calls = {"n": 0}
def fake_create(**kw):
    calls["n"] += 1
    return tool_resp('{"name": "Ada"}' if calls["n"] == 1 else '{"name": "Ada", "age": 37}')

messages = [{"role": "user", "content": "Ada is 37 years old"}]
retry_sync(func=fake_create, response_model=Answer, args=(),
           kwargs={"model": "gpt-4o-mini", "messages": messages},
           mode=Mode.TOOLS, provider=Provider.OPENAI, max_retries=2)
print(len(messages))  # 3 — expected 1; caller's list was mutated

Expected behavior

Retrying must not modify caller-owned request state. The patched-client path already guarantees this — patch.py isolates messages/contents/chat_history via isolate_retry_kwargs before entering the retry loop — so direct users of the public retry API should get the same guarantee.

Root cause

retry_sync_v2 / retry_async_v2 pass the caller's kwargs straight into the retry loop, and handle_reask_kwargs uses a shallow kwargs.copy(). Provider reask handlers (reask_tools, reask_json, reask_anthropic_tools, GenAI contents, Cohere chat_history, …) call kwargs["messages"].append/extend(...), which mutates the still-shared nested list.

Proposed fix

Call the existing isolate_retry_kwargs helper at the top of retry_sync_v2/retry_async_v2 and inside handle_reask_kwargs, matching the isolation patch.py already performs.

Environment

instructor main @ e12f8b4 (v1.17.1+), Python 3.12, Linux.