Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ plus additional fields specific to the current tool call:
- `tool_arguments` – the raw argument string passed to the tool
- `tool_namespace` – the Responses namespace for the tool call, when the tool was loaded through `tool_namespace()` or another namespaced surface
- `qualified_tool_name` – the tool name qualified with the namespace when one is available
- `conversation_history` – a visible history snapshot available to the tool at invocation time. For local function tools in non-streaming runs, this includes the current input plus prior visible run items that can be represented as model input.

Use `ToolContext` when you need tool-level metadata during execution.
For general context sharing between agents and tools, `RunContextWrapper` remains sufficient. Because `ToolContext` extends `RunContextWrapper`, it can also expose `.tool_input` when a nested `Agent.as_tool()` run supplied structured input.
Expand Down
1 change: 1 addition & 0 deletions src/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ async def _run_agent_impl(context: ToolContext, input_json: str) -> Any:
tool_namespace=context.tool_namespace,
agent=context.agent,
run_config=resolved_run_config,
conversation_history=context.conversation_history,
)
set_agent_tool_state_scope(nested_context, tool_state_scope_id)
if should_capture_tool_input:
Expand Down
4 changes: 4 additions & 0 deletions src/agents/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ def _populate_state_from_result(
state._reasoning_item_id_policy = getattr(result, "_reasoning_item_id_policy", None)

interruptions = list(getattr(result, "interruptions", []))
if interruptions:
state._interrupted_turn_input = copy.deepcopy(result.context_wrapper._tool_history_input)
else:
state._interrupted_turn_input = None
if interruptions:
state._current_step = NextStepInterruption(interruptions=interruptions)

Expand Down
14 changes: 14 additions & 0 deletions src/agents/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,13 @@ async def run(
session_items = []
model_responses = []
context_wrapper = ensure_context_wrapper(context)
preserve_tool_history = (
conversation_id is not None
and context_wrapper._tool_history_conversation_id == conversation_id
)
Comment on lines +573 to +576

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve history for previous_response_id server runs

This reset condition preserves _tool_history_input only when an explicit conversation_id matches, so reused RunContextWrapper instances in previous_response_id/auto_previous_response_id flows always get cleared at the start of each new Runner.run. In run_internal/run_loop.py, _tool_history_conversation_id is populated from server_conversation_tracker.conversation_id, which is None in those flows, so follow-up tool calls receive only the latest delta while the model still has full server-managed history; that makes ToolContext.conversation_history diverge from the context that actually produced the tool call.

Useful? React with 👍 / 👎.

if not preserve_tool_history:
context_wrapper._tool_history_input = []
context_wrapper._tool_history_conversation_id = None
set_agent_tool_state_scope(context_wrapper, None)
run_state = RunState(
context=context_wrapper,
Expand Down Expand Up @@ -1505,6 +1512,13 @@ def run_streamed(
auto_previous_response_id=auto_previous_response_id,
)
context_wrapper = ensure_context_wrapper(context)
preserve_tool_history = (
conversation_id is not None
and context_wrapper._tool_history_conversation_id == conversation_id
)
if not preserve_tool_history:
context_wrapper._tool_history_input = []
context_wrapper._tool_history_conversation_id = None
set_agent_tool_state_scope(context_wrapper, None)
# input_for_state is the same as input_for_result here
input_for_state = input_for_result
Expand Down
8 changes: 8 additions & 0 deletions src/agents/run_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class RunContextWrapper(Generic[TContext]):
"""

turn_input: list[TResponseInputItem] = field(default_factory=list)
_tool_history_input: list[TResponseInputItem] = field(
default_factory=list, repr=False, init=False
)
_tool_history_conversation_id: str | None = field(default=None, repr=False, init=False)
_approvals: dict[str, _ApprovalRecord] = field(default_factory=dict)
tool_input: Any | None = None
"""Structured input for the current agent tool run, when available."""
Expand Down Expand Up @@ -460,6 +464,8 @@ def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]:
fork.usage = self.usage
fork._approvals = self._approvals
fork.turn_input = self.turn_input
fork._tool_history_input = self._tool_history_input
fork._tool_history_conversation_id = self._tool_history_conversation_id
fork.tool_input = tool_input
return fork

Expand All @@ -469,6 +475,8 @@ def _fork_without_tool_input(self) -> RunContextWrapper[TContext]:
fork.usage = self.usage
fork._approvals = self._approvals
fork.turn_input = self.turn_input
fork._tool_history_input = self._tool_history_input
fork._tool_history_conversation_id = self._tool_history_conversation_id
return fork


Expand Down
6 changes: 6 additions & 0 deletions src/agents/run_internal/agent_runner_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import copy
from typing import Any, cast

from ..agent import Agent
Expand Down Expand Up @@ -311,6 +312,11 @@ def update_run_state_for_interruption(
run_state._session_items = list(session_items)
run_state._current_step = next_step
run_state._current_turn = current_turn
run_state._interrupted_turn_input = (
copy.deepcopy(run_state._context._tool_history_input)
if run_state._context is not None
else None
)


async def save_turn_items_if_needed(
Expand Down
20 changes: 20 additions & 0 deletions src/agents/run_internal/run_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import asyncio
import copy
import dataclasses as _dc
import json
from collections.abc import Awaitable, Callable, Mapping
Expand Down Expand Up @@ -986,6 +987,9 @@ async def _save_stream_items_without_count(
run_state._session_items = list(streamed_result.new_items)
run_state._current_step = turn_result.next_step
run_state._current_turn = current_turn
run_state._interrupted_turn_input = copy.deepcopy(
context_wrapper._tool_history_input
)
run_state._current_turn_persisted_item_count = (
streamed_result._current_turn_persisted_item_count
)
Expand Down Expand Up @@ -1189,6 +1193,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
reasoning_item_id_policy,
)

prior_tool_history_input = list(context_wrapper._tool_history_input)
filtered = await maybe_filter_model_input(
agent=agent,
run_config=run_config,
Expand All @@ -1198,6 +1203,13 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
)
if isinstance(filtered.input, list):
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
context_wrapper._tool_history_input = list(filtered.input)
if server_conversation_tracker is not None:
context_wrapper._tool_history_input = prepare_model_input_items(
prior_tool_history_input,
context_wrapper._tool_history_input,
)
context_wrapper._tool_history_conversation_id = server_conversation_tracker.conversation_id
hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(streamed_result._model_input_items)
if isinstance(filtered.input, list):
hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(filtered.input))
Expand Down Expand Up @@ -1529,6 +1541,7 @@ async def run_single_turn(
else:
input = _prepare_turn_input_items(original_input, generated_items, reasoning_item_id_policy)

prior_tool_history_input = list(context_wrapper._tool_history_input)
new_response = await get_new_response(
agent,
system_prompt,
Expand All @@ -1545,6 +1558,12 @@ async def run_single_turn(
session=session,
session_items_to_rewind=session_items_to_rewind,
)
if server_conversation_tracker is not None:
context_wrapper._tool_history_input = prepare_model_input_items(
prior_tool_history_input,
context_wrapper._tool_history_input,
)
context_wrapper._tool_history_conversation_id = server_conversation_tracker.conversation_id

return await get_single_step_result_from_response(
agent=agent,
Expand Down Expand Up @@ -1587,6 +1606,7 @@ async def get_new_response(
)
if isinstance(filtered.input, list):
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
context_wrapper._tool_history_input = list(filtered.input)

model = get_model(agent, run_config)
model_settings = agent.model_settings.resolve(run_config.model_settings)
Expand Down
8 changes: 8 additions & 0 deletions src/agents/run_internal/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
RunItemBase,
ToolApprovalItem,
ToolCallOutputItem,
TResponseInputItem,
)
from ..logger import logger
from ..model_settings import ModelSettings
Expand Down Expand Up @@ -1284,13 +1285,17 @@ def __init__(
hooks: RunHooks[Any],
context_wrapper: RunContextWrapper[Any],
config: RunConfig,
conversation_history: list[TResponseInputItem] | None,
isolate_parallel_failures: bool | None,
) -> None:
self.agent = agent
self.tool_runs = tool_runs
self.hooks = hooks
self.context_wrapper = context_wrapper
self.config = config
self.conversation_history = (
list(conversation_history) if conversation_history is not None else None
)
self.isolate_parallel_failures = (
len(tool_runs) > 1 if isolate_parallel_failures is None else isolate_parallel_failures
)
Expand Down Expand Up @@ -1465,6 +1470,7 @@ async def _run_single_tool(
tool_namespace=tool_context_namespace,
agent=self.agent,
run_config=self.config,
conversation_history=self.conversation_history,
)
agent_hooks = self.agent.hooks
if self.config.trace_include_sensitive_data:
Expand Down Expand Up @@ -1797,6 +1803,7 @@ async def execute_function_tool_calls(
hooks: RunHooks[Any],
context_wrapper: RunContextWrapper[Any],
config: RunConfig,
conversation_history: list[TResponseInputItem] | None = None,
isolate_parallel_failures: bool | None = None,
) -> tuple[
list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult]
Expand All @@ -1808,6 +1815,7 @@ async def execute_function_tool_calls(
hooks=hooks,
context_wrapper=context_wrapper,
config=config,
conversation_history=conversation_history,
isolate_parallel_failures=isolate_parallel_failures,
).execute()

Expand Down
4 changes: 4 additions & 0 deletions src/agents/run_internal/tool_planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ToolApprovalItem,
ToolCallItem,
ToolCallOutputItem,
TResponseInputItem,
)
from ..run_context import RunContextWrapper
from ..tool import FunctionTool, MCPToolApprovalRequest
Expand Down Expand Up @@ -522,6 +523,7 @@ async def _execute_tool_plan(
hooks,
context_wrapper: RunContextWrapper[Any],
run_config,
conversation_history: list[TResponseInputItem] | None = None,
parallel: bool = True,
) -> tuple[
list[Any],
Expand Down Expand Up @@ -556,6 +558,7 @@ async def _execute_tool_plan(
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
conversation_history=conversation_history,
isolate_parallel_failures=isolate_function_tool_failures,
),
execute_computer_actions(
Expand Down Expand Up @@ -598,6 +601,7 @@ async def _execute_tool_plan(
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
conversation_history=conversation_history,
isolate_parallel_failures=isolate_function_tool_failures,
)
computer_results = await execute_computer_actions(
Expand Down
47 changes: 47 additions & 0 deletions src/agents/run_internal/turn_resolution.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import copy
import inspect
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Literal, cast
Expand Down Expand Up @@ -87,7 +88,10 @@
from .items import (
REJECTION_MESSAGE,
apply_patch_rejection_item,
deduplicate_input_items_preferring_latest,
function_rejection_item,
prepare_model_input_items,
run_items_to_input_items,
shell_rejection_item,
)
from .run_steps import (
Expand Down Expand Up @@ -139,6 +143,7 @@
_make_unique_item_appender,
_select_function_tool_runs_for_resume,
)
from .turn_preparation import maybe_filter_model_input

__all__ = [
"execute_final_output_step",
Expand All @@ -153,6 +158,13 @@
]


def _build_function_tool_conversation_history(
turn_input: Sequence[TResponseInputItem],
) -> list[TResponseInputItem]:
"""Build the visible history snapshot for a local function tool invocation."""
return list(turn_input)
Comment on lines +161 to +165

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deep-copy conversation history before passing to tools

This helper builds conversation_history with only a shallow list copy, so each history item object is still shared with context_wrapper._tool_history_input. If a tool mutates a history item (for example editing content or IDs), it mutates the runner’s internal snapshot that is later reused for interruption state and server-history accumulation, which can corrupt subsequent model input and resume behavior even though the field is documented as a snapshot.

Useful? React with 👍 / 👎.



async def _maybe_finalize_from_tool_results(
*,
agent: Agent[TContext],
Expand Down Expand Up @@ -528,6 +540,10 @@ async def execute_tools_and_side_effects(
new_items=processed_response.new_items,
)

conversation_history = _build_function_tool_conversation_history(
context_wrapper._tool_history_input
)

(
function_results,
tool_input_guardrail_results,
Expand All @@ -542,6 +558,7 @@ async def execute_tools_and_side_effects(
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
conversation_history=conversation_history,
)
new_step_items.extend(
_build_tool_result_items(
Expand Down Expand Up @@ -1103,6 +1120,35 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
apply_patch_calls=approved_apply_patch_calls,
)

resolved_reasoning_item_id_policy = (
run_config.reasoning_item_id_policy
if run_config.reasoning_item_id_policy is not None
else (run_state._reasoning_item_id_policy if run_state is not None else None)
)
if run_state is not None and isinstance(run_state._interrupted_turn_input, list):
context_wrapper._tool_history_input = copy.deepcopy(run_state._interrupted_turn_input)
else:
reconstructed_turn_input = prepare_model_input_items(
ItemHelpers.input_to_new_input_list(original_input),
run_items_to_input_items(original_pre_step_items, resolved_reasoning_item_id_policy),
)
system_prompt = await agent.get_system_prompt(context_wrapper)
filtered_model_input = await maybe_filter_model_input(
agent=agent,
run_config=run_config,
context_wrapper=context_wrapper,
input_items=reconstructed_turn_input,
system_instructions=system_prompt,
)
if isinstance(filtered_model_input.input, list):
filtered_model_input.input = deduplicate_input_items_preferring_latest(
filtered_model_input.input
)
context_wrapper._tool_history_input = list(filtered_model_input.input)
conversation_history = _build_function_tool_conversation_history(
context_wrapper._tool_history_input
)

(
function_results,
tool_input_guardrail_results,
Expand All @@ -1117,6 +1163,7 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
conversation_history=conversation_history,
)

for interruption in _collect_tool_interruptions(
Expand Down
Loading