Skip to content

Commit 8c353f9

Browse files
committed
feat: add conversation history to ToolContext
1 parent 8db2ed2 commit 8c353f9

16 files changed

+492
-4
lines changed

docs/context.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ plus additional fields specific to the current tool call:
126126
- `tool_arguments` – the raw argument string passed to the tool
127127
- `tool_namespace` – the Responses namespace for the tool call, when the tool was loaded through `tool_namespace()` or another namespaced surface
128128
- `qualified_tool_name` – the tool name qualified with the namespace when one is available
129+
- `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.
129130

130131
Use `ToolContext` when you need tool-level metadata during execution.
131132
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.

src/agents/agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ async def _run_agent_impl(context: ToolContext, input_json: str) -> Any:
602602
tool_namespace=context.tool_namespace,
603603
agent=context.agent,
604604
run_config=resolved_run_config,
605+
conversation_history=context.conversation_history,
605606
)
606607
set_agent_tool_state_scope(nested_context, tool_state_scope_id)
607608
if should_capture_tool_input:

src/agents/result.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ def _populate_state_from_result(
9999
state._reasoning_item_id_policy = getattr(result, "_reasoning_item_id_policy", None)
100100

101101
interruptions = list(getattr(result, "interruptions", []))
102+
if interruptions:
103+
state._interrupted_turn_input = copy.deepcopy(result.context_wrapper._tool_history_input)
104+
else:
105+
state._interrupted_turn_input = None
102106
if interruptions:
103107
state._current_step = NextStepInterruption(interruptions=interruptions)
104108

src/agents/run_context.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class RunContextWrapper(Generic[TContext]):
5757
"""
5858

5959
turn_input: list[TResponseInputItem] = field(default_factory=list)
60+
_tool_history_input: list[TResponseInputItem] = field(default_factory=list, repr=False)
6061
_approvals: dict[str, _ApprovalRecord] = field(default_factory=dict)
6162
tool_input: Any | None = None
6263
"""Structured input for the current agent tool run, when available."""
@@ -460,6 +461,7 @@ def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]:
460461
fork.usage = self.usage
461462
fork._approvals = self._approvals
462463
fork.turn_input = self.turn_input
464+
fork._tool_history_input = self._tool_history_input
463465
fork.tool_input = tool_input
464466
return fork
465467

@@ -469,6 +471,7 @@ def _fork_without_tool_input(self) -> RunContextWrapper[TContext]:
469471
fork.usage = self.usage
470472
fork._approvals = self._approvals
471473
fork.turn_input = self.turn_input
474+
fork._tool_history_input = self._tool_history_input
472475
return fork
473476

474477

src/agents/run_internal/agent_runner_helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import copy
56
from typing import Any, cast
67

78
from ..agent import Agent
@@ -311,6 +312,11 @@ def update_run_state_for_interruption(
311312
run_state._session_items = list(session_items)
312313
run_state._current_step = next_step
313314
run_state._current_turn = current_turn
315+
run_state._interrupted_turn_input = (
316+
copy.deepcopy(run_state._context._tool_history_input)
317+
if run_state._context is not None
318+
else None
319+
)
314320

315321

316322
async def save_turn_items_if_needed(

src/agents/run_internal/run_loop.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import copy
910
import dataclasses as _dc
1011
import json
1112
from collections.abc import Awaitable, Callable, Mapping
@@ -986,6 +987,9 @@ async def _save_stream_items_without_count(
986987
run_state._session_items = list(streamed_result.new_items)
987988
run_state._current_step = turn_result.next_step
988989
run_state._current_turn = current_turn
990+
run_state._interrupted_turn_input = copy.deepcopy(
991+
context_wrapper._tool_history_input
992+
)
989993
run_state._current_turn_persisted_item_count = (
990994
streamed_result._current_turn_persisted_item_count
991995
)
@@ -1189,6 +1193,7 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
11891193
reasoning_item_id_policy,
11901194
)
11911195

1196+
prior_tool_history_input = list(context_wrapper._tool_history_input)
11921197
filtered = await maybe_filter_model_input(
11931198
agent=agent,
11941199
run_config=run_config,
@@ -1198,6 +1203,12 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
11981203
)
11991204
if isinstance(filtered.input, list):
12001205
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
1206+
context_wrapper._tool_history_input = list(filtered.input)
1207+
if server_conversation_tracker is not None:
1208+
context_wrapper._tool_history_input = prepare_model_input_items(
1209+
prior_tool_history_input,
1210+
context_wrapper._tool_history_input,
1211+
)
12011212
hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(streamed_result._model_input_items)
12021213
if isinstance(filtered.input, list):
12031214
hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(filtered.input))
@@ -1529,6 +1540,7 @@ async def run_single_turn(
15291540
else:
15301541
input = _prepare_turn_input_items(original_input, generated_items, reasoning_item_id_policy)
15311542

1543+
prior_tool_history_input = list(context_wrapper._tool_history_input)
15321544
new_response = await get_new_response(
15331545
agent,
15341546
system_prompt,
@@ -1545,6 +1557,11 @@ async def run_single_turn(
15451557
session=session,
15461558
session_items_to_rewind=session_items_to_rewind,
15471559
)
1560+
if server_conversation_tracker is not None:
1561+
context_wrapper._tool_history_input = prepare_model_input_items(
1562+
prior_tool_history_input,
1563+
context_wrapper._tool_history_input,
1564+
)
15481565

15491566
return await get_single_step_result_from_response(
15501567
agent=agent,
@@ -1587,6 +1604,7 @@ async def get_new_response(
15871604
)
15881605
if isinstance(filtered.input, list):
15891606
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
1607+
context_wrapper._tool_history_input = list(filtered.input)
15901608

15911609
model = get_model(agent, run_config)
15921610
model_settings = agent.model_settings.resolve(run_config.model_settings)

src/agents/run_internal/tool_execution.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
RunItemBase,
5656
ToolApprovalItem,
5757
ToolCallOutputItem,
58+
TResponseInputItem,
5859
)
5960
from ..logger import logger
6061
from ..model_settings import ModelSettings
@@ -1284,13 +1285,17 @@ def __init__(
12841285
hooks: RunHooks[Any],
12851286
context_wrapper: RunContextWrapper[Any],
12861287
config: RunConfig,
1288+
conversation_history: list[TResponseInputItem] | None,
12871289
isolate_parallel_failures: bool | None,
12881290
) -> None:
12891291
self.agent = agent
12901292
self.tool_runs = tool_runs
12911293
self.hooks = hooks
12921294
self.context_wrapper = context_wrapper
12931295
self.config = config
1296+
self.conversation_history = (
1297+
list(conversation_history) if conversation_history is not None else None
1298+
)
12941299
self.isolate_parallel_failures = (
12951300
len(tool_runs) > 1 if isolate_parallel_failures is None else isolate_parallel_failures
12961301
)
@@ -1465,6 +1470,7 @@ async def _run_single_tool(
14651470
tool_namespace=tool_context_namespace,
14661471
agent=self.agent,
14671472
run_config=self.config,
1473+
conversation_history=self.conversation_history,
14681474
)
14691475
agent_hooks = self.agent.hooks
14701476
if self.config.trace_include_sensitive_data:
@@ -1797,6 +1803,7 @@ async def execute_function_tool_calls(
17971803
hooks: RunHooks[Any],
17981804
context_wrapper: RunContextWrapper[Any],
17991805
config: RunConfig,
1806+
conversation_history: list[TResponseInputItem] | None = None,
18001807
isolate_parallel_failures: bool | None = None,
18011808
) -> tuple[
18021809
list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult]
@@ -1808,6 +1815,7 @@ async def execute_function_tool_calls(
18081815
hooks=hooks,
18091816
context_wrapper=context_wrapper,
18101817
config=config,
1818+
conversation_history=conversation_history,
18111819
isolate_parallel_failures=isolate_parallel_failures,
18121820
).execute()
18131821

src/agents/run_internal/tool_planning.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ToolApprovalItem,
2121
ToolCallItem,
2222
ToolCallOutputItem,
23+
TResponseInputItem,
2324
)
2425
from ..run_context import RunContextWrapper
2526
from ..tool import FunctionTool, MCPToolApprovalRequest
@@ -522,6 +523,7 @@ async def _execute_tool_plan(
522523
hooks,
523524
context_wrapper: RunContextWrapper[Any],
524525
run_config,
526+
conversation_history: list[TResponseInputItem] | None = None,
525527
parallel: bool = True,
526528
) -> tuple[
527529
list[Any],
@@ -556,6 +558,7 @@ async def _execute_tool_plan(
556558
hooks=hooks,
557559
context_wrapper=context_wrapper,
558560
config=run_config,
561+
conversation_history=conversation_history,
559562
isolate_parallel_failures=isolate_function_tool_failures,
560563
),
561564
execute_computer_actions(
@@ -598,6 +601,7 @@ async def _execute_tool_plan(
598601
hooks=hooks,
599602
context_wrapper=context_wrapper,
600603
config=run_config,
604+
conversation_history=conversation_history,
601605
isolate_parallel_failures=isolate_function_tool_failures,
602606
)
603607
computer_results = await execute_computer_actions(

src/agents/run_internal/turn_resolution.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import copy
45
import inspect
56
from collections.abc import Awaitable, Callable, Mapping, Sequence
67
from typing import Any, Literal, cast
@@ -87,7 +88,10 @@
8788
from .items import (
8889
REJECTION_MESSAGE,
8990
apply_patch_rejection_item,
91+
deduplicate_input_items_preferring_latest,
9092
function_rejection_item,
93+
prepare_model_input_items,
94+
run_items_to_input_items,
9195
shell_rejection_item,
9296
)
9397
from .run_steps import (
@@ -139,6 +143,7 @@
139143
_make_unique_item_appender,
140144
_select_function_tool_runs_for_resume,
141145
)
146+
from .turn_preparation import maybe_filter_model_input
142147

143148
__all__ = [
144149
"execute_final_output_step",
@@ -153,6 +158,13 @@
153158
]
154159

155160

161+
def _build_function_tool_conversation_history(
162+
turn_input: Sequence[TResponseInputItem],
163+
) -> list[TResponseInputItem]:
164+
"""Build the visible history snapshot for a local function tool invocation."""
165+
return list(turn_input)
166+
167+
156168
async def _maybe_finalize_from_tool_results(
157169
*,
158170
agent: Agent[TContext],
@@ -528,6 +540,10 @@ async def execute_tools_and_side_effects(
528540
new_items=processed_response.new_items,
529541
)
530542

543+
conversation_history = _build_function_tool_conversation_history(
544+
context_wrapper._tool_history_input
545+
)
546+
531547
(
532548
function_results,
533549
tool_input_guardrail_results,
@@ -542,6 +558,7 @@ async def execute_tools_and_side_effects(
542558
hooks=hooks,
543559
context_wrapper=context_wrapper,
544560
run_config=run_config,
561+
conversation_history=conversation_history,
545562
)
546563
new_step_items.extend(
547564
_build_tool_result_items(
@@ -1103,6 +1120,35 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
11031120
apply_patch_calls=approved_apply_patch_calls,
11041121
)
11051122

1123+
resolved_reasoning_item_id_policy = (
1124+
run_config.reasoning_item_id_policy
1125+
if run_config.reasoning_item_id_policy is not None
1126+
else (run_state._reasoning_item_id_policy if run_state is not None else None)
1127+
)
1128+
if run_state is not None and isinstance(run_state._interrupted_turn_input, list):
1129+
context_wrapper._tool_history_input = copy.deepcopy(run_state._interrupted_turn_input)
1130+
else:
1131+
reconstructed_turn_input = prepare_model_input_items(
1132+
ItemHelpers.input_to_new_input_list(original_input),
1133+
run_items_to_input_items(original_pre_step_items, resolved_reasoning_item_id_policy),
1134+
)
1135+
system_prompt = await agent.get_system_prompt(context_wrapper)
1136+
filtered_model_input = await maybe_filter_model_input(
1137+
agent=agent,
1138+
run_config=run_config,
1139+
context_wrapper=context_wrapper,
1140+
input_items=reconstructed_turn_input,
1141+
system_instructions=system_prompt,
1142+
)
1143+
if isinstance(filtered_model_input.input, list):
1144+
filtered_model_input.input = deduplicate_input_items_preferring_latest(
1145+
filtered_model_input.input
1146+
)
1147+
context_wrapper._tool_history_input = list(filtered_model_input.input)
1148+
conversation_history = _build_function_tool_conversation_history(
1149+
context_wrapper._tool_history_input
1150+
)
1151+
11061152
(
11071153
function_results,
11081154
tool_input_guardrail_results,
@@ -1117,6 +1163,7 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
11171163
hooks=hooks,
11181164
context_wrapper=context_wrapper,
11191165
run_config=run_config,
1166+
conversation_history=conversation_history,
11201167
)
11211168

11221169
for interruption in _collect_tool_interruptions(

src/agents/run_state.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,9 @@
118118
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
119119
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported
120120
# versions).
121-
CURRENT_SCHEMA_VERSION = "1.6"
121+
CURRENT_SCHEMA_VERSION = "1.7"
122122
SUPPORTED_SCHEMA_VERSIONS = frozenset(
123-
{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", CURRENT_SCHEMA_VERSION}
123+
{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", CURRENT_SCHEMA_VERSION}
124124
)
125125

126126
_FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput)
@@ -187,6 +187,9 @@ class RunState(Generic[TContext, TAgent]):
187187
_reasoning_item_id_policy: Literal["preserve", "omit"] | None = None
188188
"""How reasoning item IDs are represented in next-turn model input."""
189189

190+
_interrupted_turn_input: list[TResponseInputItem] | None = None
191+
"""Filtered turn input snapshot for the currently interrupted turn, if any."""
192+
190193
_input_guardrail_results: list[InputGuardrailResult] = field(default_factory=list)
191194
"""Results from input guardrails applied to the run."""
192195

@@ -240,6 +243,7 @@ def __init__(
240243
self._previous_response_id = previous_response_id
241244
self._auto_previous_response_id = auto_previous_response_id
242245
self._reasoning_item_id_policy = None
246+
self._interrupted_turn_input = None
243247
self._model_responses = []
244248
self._generated_items = []
245249
self._session_items = []
@@ -657,6 +661,7 @@ def to_json(
657661
"previous_response_id": self._previous_response_id,
658662
"auto_previous_response_id": self._auto_previous_response_id,
659663
"reasoning_item_id_policy": self._reasoning_item_id_policy,
664+
"interrupted_turn_input": copy.deepcopy(self._interrupted_turn_input),
660665
}
661666

662667
generated_items = self._merge_generated_items_with_processed()
@@ -2288,6 +2293,11 @@ async def _build_run_state_from_json(
22882293
state._reasoning_item_id_policy = cast(Literal["preserve", "omit"], serialized_policy)
22892294
else:
22902295
state._reasoning_item_id_policy = None
2296+
serialized_interrupted_turn_input = state_json.get("interrupted_turn_input")
2297+
if isinstance(serialized_interrupted_turn_input, list):
2298+
state._interrupted_turn_input = copy.deepcopy(serialized_interrupted_turn_input)
2299+
else:
2300+
state._interrupted_turn_input = None
22912301
state.set_tool_use_tracker_snapshot(state_json.get("tool_use_tracker", {}))
22922302
trace_data = state_json.get("trace")
22932303
if isinstance(trace_data, Mapping):

0 commit comments

Comments
 (0)