From 2a4bca7b4f46d4e3175908efea38aa92f9f74b3d Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:22:30 -0500 Subject: [PATCH 1/5] Add test for multi-agent instruction handling (#3207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test case that reproduces issue #3207 where Agent2's instructions are ignored when running sequentially with Agent1's message history. The test verifies that each agent uses its own instructions rather than inheriting instructions from previous agents in the workflow. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/test_multi_agent_instructions.py | 148 +++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/test_multi_agent_instructions.py diff --git a/tests/test_multi_agent_instructions.py b/tests/test_multi_agent_instructions.py new file mode 100644 index 0000000000..2636486887 --- /dev/null +++ b/tests/test_multi_agent_instructions.py @@ -0,0 +1,148 @@ +"""Test for issue #3207: Instructions ignored in back-to-back agent calls. + +This test verifies that when running multiple agents sequentially with message_history, +each agent uses its own instructions rather than inheriting instructions from previous agents. +""" + +from pydantic import BaseModel, Field + +from pydantic_ai import Agent, ModelRequest, capture_run_messages +from pydantic_ai.models.test import TestModel + + +class Suggestion(BaseModel): + """A suggestion for the user.""" + + text: str = Field(description='The suggestion text') + + +def test_multi_agent_sequential_instructions_with_output_type(): + """Test that Agent2 uses its own instructions when called with Agent1's message history. + + This reproduces issue #3207 where Agent2's instructions were being ignored + and Agent1's instructions were used instead. + + Scenario: + 1. Agent1 runs with instructions="Agent 1 instructions" + 2. Agent2 runs with message_history from Agent1 + output_type (structured output) + 3. Expected: Agent2's ModelRequest should have instructions="Agent 2 instructions" + 4. Bug: Agent2's ModelRequest incorrectly uses Agent1's instructions + """ + # Create two agents with different instructions + model1 = TestModel() + agent1 = Agent( + model1, + instructions='Agent 1 instructions', + ) + + # Use a second TestModel instance to track what instructions it receives + model2 = TestModel(custom_output_args={'text': 'Test suggestion'}) + agent2 = Agent( + model2, + instructions='Agent 2 instructions', + output_type=Suggestion, + output_retries=5, # Allow more retries to capture the messages even if validation fails + ) + + # Run Agent1 + result1 = agent1.run_sync('Hello') + + # Run Agent2 with Agent1's message history, capturing messages even if it fails + # This is the scenario that triggers the bug in issue #3207 + with capture_run_messages() as agent2_messages: + try: + agent2.run_sync(message_history=result1.new_messages()) + except Exception: + # Even if Agent2 fails, we captured the messages it created + pass + + # Find all ModelRequest messages created by Agent2 + agent2_requests = [msg for msg in agent2_messages if isinstance(msg, ModelRequest)] + + # We expect at least one ModelRequest from Agent2 + assert len(agent2_requests) > 0, 'Agent2 should have created at least one ModelRequest' + + # Check what instructions were used in Agent2's requests + instructions_in_agent2_requests = [req.instructions for req in agent2_requests if req.instructions is not None] + + # Agent2 should use its own instructions, not Agent1's + agent2_instructions_found = 'Agent 2 instructions' in instructions_in_agent2_requests + + # This assertion will FAIL with the bug (Agent1's instructions are used instead) + assert agent2_instructions_found, ( + f'BUG REPRODUCED: Agent 2 instructions not found in requests created by Agent2.\n' + f'Expected: "Agent 2 instructions"\n' + f'Found: {instructions_in_agent2_requests}\n' + f"This confirms issue #3207 - Agent1's instructions are leaking into Agent2's requests." + ) + + +def test_multi_agent_sequential_instructions_no_output_type(): + """Test multi-agent instructions without structured output. + + This is a simpler scenario that should also work correctly. + """ + agent1 = Agent( + TestModel(), + instructions='Agent 1 instructions', + ) + agent2 = Agent( + TestModel(), + instructions='Agent 2 instructions', + ) + + # Run Agent1 + result1 = agent1.run_sync('Hello') + + # Run Agent2 with Agent1's message history + result2 = agent2.run_sync('Hello again', message_history=result1.new_messages()) + + # Agent2's new requests should have Agent2's instructions + agent2_new_requests = [msg for msg in result2.new_messages() if isinstance(msg, ModelRequest)] + + # At least one of Agent2's new requests should have its instructions + agent2_instructions_found = any( + req.instructions == 'Agent 2 instructions' for req in agent2_new_requests if req.instructions is not None + ) + + assert agent2_instructions_found, ( + f'Agent 2 instructions not found in new requests. ' + f'Instructions: {[req.instructions for req in agent2_new_requests if req.instructions is not None]}' + ) + + +def test_multi_agent_with_user_prompt_workaround(): + """Test that passing a user_prompt to Agent2 avoids the bug. + + This is the workaround mentioned in issue #3207. + When a user_prompt is provided, Agent2 creates a fresh ModelRequest + with its own instructions. + """ + agent1 = Agent( + TestModel(), + instructions='Agent 1 instructions', + ) + agent2 = Agent( + TestModel(), + instructions='Agent 2 instructions', + output_type=Suggestion, + ) + + # Run Agent1 + result1 = agent1.run_sync('Hello') + + # Run Agent2 WITH a user_prompt (workaround) + result2 = agent2.run_sync('Continue', message_history=result1.new_messages()) + + # Get Agent2's new requests + agent2_new_requests = [msg for msg in result2.new_messages() if isinstance(msg, ModelRequest)] + + # Should have Agent2's instructions + agent2_instructions_found = any( + req.instructions == 'Agent 2 instructions' for req in agent2_new_requests if req.instructions is not None + ) + + assert agent2_instructions_found, ( + f'Agent 2 instructions not found even with user_prompt workaround. ' + f'Instructions: {[req.instructions for req in agent2_new_requests if req.instructions is not None]}' + ) From 15901eee3dd512120054cc98df4082b1f145f30d Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:22:56 -0500 Subject: [PATCH 2/5] Fix multi-agent instruction handling in sequential workflows (#3207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running multiple agents sequentially with message_history, retry requests created by CallToolsNode were missing instructions. This caused Model._get_instructions() to fall back to searching message history and incorrectly return the previous agent's instructions instead of the current agent's. Fixed by ensuring all retry ModelRequest objects created in CallToolsNode are initialized with instructions from the current agent via ctx.deps.get_instructions(run_context). Changes: - Set instructions on empty retry requests (line 572-576) - Set instructions on tool retry requests (line 638-642) - Update test assertion to expect instructions on retry requests Fixes #3207 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 12 ++++++++++-- tests/test_agent.py | 9 ++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 3867473d1f..7c7ad65247 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -569,7 +569,11 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa # as the empty response and request will not create any items in the API payload, # in the hope the model will return a non-empty response this time. ctx.state.increment_retries(ctx.deps.max_result_retries) - self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[])) + run_context = build_run_context(ctx) + instructions = await ctx.deps.get_instructions(run_context) + self._next_node = ModelRequestNode[DepsT, NodeRunEndT]( + _messages.ModelRequest(parts=[], instructions=instructions) + ) return text = '' @@ -631,7 +635,11 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa raise ToolRetryError(m) except ToolRetryError as e: ctx.state.increment_retries(ctx.deps.max_result_retries, e) - self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[e.tool_retry])) + run_context = build_run_context(ctx) + instructions = await ctx.deps.get_instructions(run_context) + self._next_node = ModelRequestNode[DepsT, NodeRunEndT]( + _messages.ModelRequest(parts=[e.tool_retry], instructions=instructions) + ) self._events_iterator = _run_stream() diff --git a/tests/test_agent.py b/tests/test_agent.py index c8beb08312..add53ec89f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1834,7 +1834,14 @@ def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: tool_call_id=IsStr(), timestamp=IsDatetime(), ) - ] + ], + instructions="""\ +Always respond with a JSON object that's compatible with this schema: + +{"additionalProperties": false, "properties": {"city": {"type": "string"}}, "required": ["city"], "type": "object", "title": "get_weather"} + +Don't include any text or Markdown fencing before or after.\ +""", ), ModelResponse( parts=[TextPart(content='{"city": "Mexico City"}')], From dfd301cd816b2a56bc00e1e4f8f419fc595531ba Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:45:43 -0500 Subject: [PATCH 3/5] Trigger CI rerun From 5f87b5845aac2476b12e0b85d724e3e2ac1bd77f Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 21 Oct 2025 02:21:55 -0500 Subject: [PATCH 4/5] Remove unnecessary exception handling in multi-agent test The try/except block in test_multi_agent_sequential_instructions_with_output_type was never executed since the fix ensures Agent2 works correctly. Removing it achieves 100% test coverage as required by CI. Related to #3207 --- tests/test_multi_agent_instructions.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_multi_agent_instructions.py b/tests/test_multi_agent_instructions.py index 2636486887..3a77a36775 100644 --- a/tests/test_multi_agent_instructions.py +++ b/tests/test_multi_agent_instructions.py @@ -47,14 +47,10 @@ def test_multi_agent_sequential_instructions_with_output_type(): # Run Agent1 result1 = agent1.run_sync('Hello') - # Run Agent2 with Agent1's message history, capturing messages even if it fails + # Run Agent2 with Agent1's message history, capturing messages # This is the scenario that triggers the bug in issue #3207 with capture_run_messages() as agent2_messages: - try: - agent2.run_sync(message_history=result1.new_messages()) - except Exception: - # Even if Agent2 fails, we captured the messages it created - pass + agent2.run_sync(message_history=result1.new_messages()) # Find all ModelRequest messages created by Agent2 agent2_requests = [msg for msg in agent2_messages if isinstance(msg, ModelRequest)] From 6482f6a3699e215622134e769cb4a5ad2f676bf6 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:15:04 -0500 Subject: [PATCH 5/5] re-add fix and move test to existing test file --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 12 +- tests/test_agent.py | 26 ++++ tests/test_multi_agent_instructions.py | 144 ------------------- 3 files changed, 36 insertions(+), 146 deletions(-) delete mode 100644 tests/test_multi_agent_instructions.py diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index f0733a8abe..33b53ca0db 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -588,7 +588,11 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa # as the empty response and request will not create any items in the API payload, # in the hope the model will return a non-empty response this time. ctx.state.increment_retries(ctx.deps.max_result_retries, model_settings=ctx.deps.model_settings) - self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[])) + run_context = build_run_context(ctx) + instructions = await ctx.deps.get_instructions(run_context) + self._next_node = ModelRequestNode[DepsT, NodeRunEndT]( + _messages.ModelRequest(parts=[], instructions=instructions) + ) return text = '' @@ -652,7 +656,11 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa ctx.state.increment_retries( ctx.deps.max_result_retries, error=e, model_settings=ctx.deps.model_settings ) - self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[e.tool_retry])) + run_context = build_run_context(ctx) + instructions = await ctx.deps.get_instructions(run_context) + self._next_node = ModelRequestNode[DepsT, NodeRunEndT]( + _messages.ModelRequest(parts=[e.tool_retry], instructions=instructions) + ) self._events_iterator = _run_stream() diff --git a/tests/test_agent.py b/tests/test_agent.py index e702151acb..baadc7dbba 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3859,6 +3859,32 @@ def empty_instructions() -> str: ) +def test_multi_agent_instructions_with_structured_output(): + """Test that Agent2 uses its own instructions when called with Agent1's history. + + Reproduces issue #3207: when running agents sequentially with no user_prompt + and structured output, Agent2's instructions were ignored. + """ + + class Output(BaseModel): + text: str + + agent1 = Agent('test', instructions='Agent 1 instructions') + agent2 = Agent('test', instructions='Agent 2 instructions', output_type=Output) + + result1 = agent1.run_sync('Hello') + + # TestModel doesn't support structured output, so this will fail with retries + # But we can still verify that Agent2's instructions are used in retry requests + with capture_run_messages() as messages: + with pytest.raises(UnexpectedModelBehavior): + agent2.run_sync(message_history=result1.new_messages()) + + # Verify Agent2's retry requests used Agent2's instructions (not Agent1's) + requests = [m for m in messages if isinstance(m, ModelRequest)] + assert any(r.instructions == 'Agent 2 instructions' for r in requests) + + def test_empty_final_response(): def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if len(messages) == 1: diff --git a/tests/test_multi_agent_instructions.py b/tests/test_multi_agent_instructions.py deleted file mode 100644 index 3a77a36775..0000000000 --- a/tests/test_multi_agent_instructions.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Test for issue #3207: Instructions ignored in back-to-back agent calls. - -This test verifies that when running multiple agents sequentially with message_history, -each agent uses its own instructions rather than inheriting instructions from previous agents. -""" - -from pydantic import BaseModel, Field - -from pydantic_ai import Agent, ModelRequest, capture_run_messages -from pydantic_ai.models.test import TestModel - - -class Suggestion(BaseModel): - """A suggestion for the user.""" - - text: str = Field(description='The suggestion text') - - -def test_multi_agent_sequential_instructions_with_output_type(): - """Test that Agent2 uses its own instructions when called with Agent1's message history. - - This reproduces issue #3207 where Agent2's instructions were being ignored - and Agent1's instructions were used instead. - - Scenario: - 1. Agent1 runs with instructions="Agent 1 instructions" - 2. Agent2 runs with message_history from Agent1 + output_type (structured output) - 3. Expected: Agent2's ModelRequest should have instructions="Agent 2 instructions" - 4. Bug: Agent2's ModelRequest incorrectly uses Agent1's instructions - """ - # Create two agents with different instructions - model1 = TestModel() - agent1 = Agent( - model1, - instructions='Agent 1 instructions', - ) - - # Use a second TestModel instance to track what instructions it receives - model2 = TestModel(custom_output_args={'text': 'Test suggestion'}) - agent2 = Agent( - model2, - instructions='Agent 2 instructions', - output_type=Suggestion, - output_retries=5, # Allow more retries to capture the messages even if validation fails - ) - - # Run Agent1 - result1 = agent1.run_sync('Hello') - - # Run Agent2 with Agent1's message history, capturing messages - # This is the scenario that triggers the bug in issue #3207 - with capture_run_messages() as agent2_messages: - agent2.run_sync(message_history=result1.new_messages()) - - # Find all ModelRequest messages created by Agent2 - agent2_requests = [msg for msg in agent2_messages if isinstance(msg, ModelRequest)] - - # We expect at least one ModelRequest from Agent2 - assert len(agent2_requests) > 0, 'Agent2 should have created at least one ModelRequest' - - # Check what instructions were used in Agent2's requests - instructions_in_agent2_requests = [req.instructions for req in agent2_requests if req.instructions is not None] - - # Agent2 should use its own instructions, not Agent1's - agent2_instructions_found = 'Agent 2 instructions' in instructions_in_agent2_requests - - # This assertion will FAIL with the bug (Agent1's instructions are used instead) - assert agent2_instructions_found, ( - f'BUG REPRODUCED: Agent 2 instructions not found in requests created by Agent2.\n' - f'Expected: "Agent 2 instructions"\n' - f'Found: {instructions_in_agent2_requests}\n' - f"This confirms issue #3207 - Agent1's instructions are leaking into Agent2's requests." - ) - - -def test_multi_agent_sequential_instructions_no_output_type(): - """Test multi-agent instructions without structured output. - - This is a simpler scenario that should also work correctly. - """ - agent1 = Agent( - TestModel(), - instructions='Agent 1 instructions', - ) - agent2 = Agent( - TestModel(), - instructions='Agent 2 instructions', - ) - - # Run Agent1 - result1 = agent1.run_sync('Hello') - - # Run Agent2 with Agent1's message history - result2 = agent2.run_sync('Hello again', message_history=result1.new_messages()) - - # Agent2's new requests should have Agent2's instructions - agent2_new_requests = [msg for msg in result2.new_messages() if isinstance(msg, ModelRequest)] - - # At least one of Agent2's new requests should have its instructions - agent2_instructions_found = any( - req.instructions == 'Agent 2 instructions' for req in agent2_new_requests if req.instructions is not None - ) - - assert agent2_instructions_found, ( - f'Agent 2 instructions not found in new requests. ' - f'Instructions: {[req.instructions for req in agent2_new_requests if req.instructions is not None]}' - ) - - -def test_multi_agent_with_user_prompt_workaround(): - """Test that passing a user_prompt to Agent2 avoids the bug. - - This is the workaround mentioned in issue #3207. - When a user_prompt is provided, Agent2 creates a fresh ModelRequest - with its own instructions. - """ - agent1 = Agent( - TestModel(), - instructions='Agent 1 instructions', - ) - agent2 = Agent( - TestModel(), - instructions='Agent 2 instructions', - output_type=Suggestion, - ) - - # Run Agent1 - result1 = agent1.run_sync('Hello') - - # Run Agent2 WITH a user_prompt (workaround) - result2 = agent2.run_sync('Continue', message_history=result1.new_messages()) - - # Get Agent2's new requests - agent2_new_requests = [msg for msg in result2.new_messages() if isinstance(msg, ModelRequest)] - - # Should have Agent2's instructions - agent2_instructions_found = any( - req.instructions == 'Agent 2 instructions' for req in agent2_new_requests if req.instructions is not None - ) - - assert agent2_instructions_found, ( - f'Agent 2 instructions not found even with user_prompt workaround. ' - f'Instructions: {[req.instructions for req in agent2_new_requests if req.instructions is not None]}' - )