Public `retry_sync`/`retry_async`/`handle_reask_kwargs` mutate the caller's `messages` list during validation retries
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:
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 mutatedExpected 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.
Source: 567-labs/instructor