Skip to content

Commit 79aac01

Browse files
committed
fix: only send new items when using conversation_id or previous_response_id in multi-turn calls
1 parent d186ded commit 79aac01

File tree

4 files changed

+520
-24
lines changed

4 files changed

+520
-24
lines changed

docs/running_agents.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,71 @@ Sessions automatically:
121121

122122
See the [Sessions documentation](sessions.md) for more details.
123123

124+
125+
### Server-managed conversations
126+
127+
You can also let the OpenAI conversation state feature manage conversation state on the server side, instead of handling it locally with `to_input_list()` or `Sessions`. This allows you to preserve conversation history without manually resending all past messages. See the [OpenAI Conversation state guide](https://platform.openai.com/docs/guides/conversation-state?api-mode=responses) for more details.
128+
129+
OpenAI provides two ways to track state across turns:
130+
131+
#### 1. Using `conversation_id`
132+
133+
You first create a conversation using the OpenAI Conversations API and then reuse its ID for every subsequent call:
134+
135+
```python
136+
from agents import Agent, Runner
137+
from openai import AsyncOpenAI
138+
139+
client = AsyncOpenAI()
140+
141+
async def main():
142+
# Create a server-managed conversation
143+
conversation = await client.conversations.create()
144+
conv_id = conversation.id
145+
146+
agent = Agent(name="Assistant", instructions="Reply very concisely.")
147+
148+
# First turn
149+
result1 = await Runner.run(agent, "What city is the Golden Gate Bridge in?", conversation_id=conv_id)
150+
print(result1.final_output)
151+
# San Francisco
152+
153+
# Second turn reuses the same conversation_id
154+
result2 = await Runner.run(
155+
agent,
156+
"What state is it in?",
157+
conversation_id=conv_id,
158+
)
159+
print(result2.final_output)
160+
# California
161+
```
162+
163+
#### 2. Using `previous_response_id`
164+
165+
Another option is **response chaining**, where each turn links explicitly to the response ID from the previous turn.
166+
167+
```python
168+
from agents import Agent, Runner
169+
170+
async def main():
171+
agent = Agent(name="Assistant", instructions="Reply very concisely.")
172+
173+
# First turn
174+
result1 = await Runner.run(agent, "What city is the Golden Gate Bridge in?")
175+
print(result1.final_output)
176+
# San Francisco
177+
178+
# Second turn, chained to the previous response
179+
result2 = await Runner.run(
180+
agent,
181+
"What state is it in?",
182+
previous_response_id=result1.last_response_id,
183+
)
184+
print(result2.final_output)
185+
# California
186+
```
187+
188+
124189
## Long running agents & human-in-the-loop
125190

126191
You can use the Agents SDK [Temporal](https://temporal.io/) integration to run durable, long-running workflows, including human-in-the-loop tasks. View a demo of Temporal and the Agents SDK working in action to complete long-running tasks [in this video](https://www.youtube.com/watch?v=fFBZqzT4DD8), and [view docs here](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/openai_agents).

src/agents/run.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,51 @@ class CallModelData(Generic[TContext]):
122122
context: TContext | None
123123

124124

125+
@dataclass
126+
class _ServerConversationTracker:
127+
"""Tracks server-side conversation state for either conversation_id or
128+
previous_response_id modes."""
129+
130+
conversation_id: str | None = None
131+
previous_response_id: str | None = None
132+
sent_items: set[int] = field(default_factory=set)
133+
server_items: set[int] = field(default_factory=set)
134+
135+
def track_server_items(self, model_response: ModelResponse) -> None:
136+
for output_item in model_response.output:
137+
self.server_items.add(id(output_item))
138+
139+
# Update previous_response_id only when using previous_response_id
140+
if (
141+
self.conversation_id is None
142+
and self.previous_response_id is not None
143+
and model_response.response_id is not None
144+
):
145+
self.previous_response_id = model_response.response_id
146+
147+
def prepare_input(
148+
self,
149+
original_input: str | list[TResponseInputItem],
150+
generated_items: list[RunItem],
151+
) -> list[TResponseInputItem]:
152+
input_items: list[TResponseInputItem] = []
153+
154+
# On first call (when there are no generated items yet), include the original input
155+
if not generated_items:
156+
input_items.extend(ItemHelpers.input_to_new_input_list(original_input))
157+
158+
# Process generated_items, skip items already sent or from server
159+
for item in generated_items:
160+
raw_item_id = id(item.raw_item)
161+
162+
if raw_item_id in self.sent_items or raw_item_id in self.server_items:
163+
continue
164+
input_items.append(item.to_input_item())
165+
self.sent_items.add(raw_item_id)
166+
167+
return input_items
168+
169+
125170
# Type alias for the optional input filter callback
126171
CallModelInputFilter = Callable[[CallModelData[Any]], MaybeAwaitable[ModelInputData]]
127172

@@ -470,6 +515,13 @@ async def run(
470515
if run_config is None:
471516
run_config = RunConfig()
472517

518+
if conversation_id is not None or previous_response_id is not None:
519+
server_conversation_tracker = _ServerConversationTracker(
520+
conversation_id=conversation_id, previous_response_id=previous_response_id
521+
)
522+
else:
523+
server_conversation_tracker = None
524+
473525
# Keep original user input separate from session-prepared input
474526
original_user_input = input
475527
prepared_input = await self._prepare_input_with_session(
@@ -563,8 +615,7 @@ async def run(
563615
run_config=run_config,
564616
should_run_agent_start_hooks=should_run_agent_start_hooks,
565617
tool_use_tracker=tool_use_tracker,
566-
previous_response_id=previous_response_id,
567-
conversation_id=conversation_id,
618+
server_conversation_tracker=server_conversation_tracker,
568619
),
569620
)
570621
else:
@@ -578,15 +629,17 @@ async def run(
578629
run_config=run_config,
579630
should_run_agent_start_hooks=should_run_agent_start_hooks,
580631
tool_use_tracker=tool_use_tracker,
581-
previous_response_id=previous_response_id,
582-
conversation_id=conversation_id,
632+
server_conversation_tracker=server_conversation_tracker,
583633
)
584634
should_run_agent_start_hooks = False
585635

586636
model_responses.append(turn_result.model_response)
587637
original_input = turn_result.original_input
588638
generated_items = turn_result.generated_items
589639

640+
if server_conversation_tracker is not None:
641+
server_conversation_tracker.track_server_items(turn_result.model_response)
642+
590643
# Collect tool guardrail results from this turn
591644
tool_input_guardrail_results.extend(turn_result.tool_input_guardrail_results)
592645
tool_output_guardrail_results.extend(turn_result.tool_output_guardrail_results)
@@ -863,6 +916,13 @@ async def _start_streaming(
863916
should_run_agent_start_hooks = True
864917
tool_use_tracker = AgentToolUseTracker()
865918

919+
if conversation_id is not None or previous_response_id is not None:
920+
server_conversation_tracker = _ServerConversationTracker(
921+
conversation_id=conversation_id, previous_response_id=previous_response_id
922+
)
923+
else:
924+
server_conversation_tracker = None
925+
866926
streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
867927

868928
try:
@@ -938,8 +998,7 @@ async def _start_streaming(
938998
should_run_agent_start_hooks,
939999
tool_use_tracker,
9401000
all_tools,
941-
previous_response_id,
942-
conversation_id,
1001+
server_conversation_tracker,
9431002
)
9441003
should_run_agent_start_hooks = False
9451004

@@ -949,6 +1008,9 @@ async def _start_streaming(
9491008
streamed_result.input = turn_result.original_input
9501009
streamed_result.new_items = turn_result.generated_items
9511010

1011+
if server_conversation_tracker is not None:
1012+
server_conversation_tracker.track_server_items(turn_result.model_response)
1013+
9521014
if isinstance(turn_result.next_step, NextStepHandoff):
9531015
current_agent = turn_result.next_step.new_agent
9541016
current_span.finish(reset_current=True)
@@ -1032,8 +1094,7 @@ async def _run_single_turn_streamed(
10321094
should_run_agent_start_hooks: bool,
10331095
tool_use_tracker: AgentToolUseTracker,
10341096
all_tools: list[Tool],
1035-
previous_response_id: str | None,
1036-
conversation_id: str | None,
1097+
server_conversation_tracker: _ServerConversationTracker | None = None,
10371098
) -> SingleStepResult:
10381099
emitted_tool_call_ids: set[str] = set()
10391100

@@ -1064,8 +1125,13 @@ async def _run_single_turn_streamed(
10641125

10651126
final_response: ModelResponse | None = None
10661127

1067-
input = ItemHelpers.input_to_new_input_list(streamed_result.input)
1068-
input.extend([item.to_input_item() for item in streamed_result.new_items])
1128+
if server_conversation_tracker is not None:
1129+
input = server_conversation_tracker.prepare_input(
1130+
streamed_result.input, streamed_result.new_items
1131+
)
1132+
else:
1133+
input = ItemHelpers.input_to_new_input_list(streamed_result.input)
1134+
input.extend([item.to_input_item() for item in streamed_result.new_items])
10691135

10701136
# THIS IS THE RESOLVED CONFLICT BLOCK
10711137
filtered = await cls._maybe_filter_model_input(
@@ -1088,6 +1154,15 @@ async def _run_single_turn_streamed(
10881154
),
10891155
)
10901156

1157+
previous_response_id = (
1158+
server_conversation_tracker.previous_response_id
1159+
if server_conversation_tracker
1160+
else None
1161+
)
1162+
conversation_id = (
1163+
server_conversation_tracker.conversation_id if server_conversation_tracker else None
1164+
)
1165+
10911166
# 1. Stream the output events
10921167
async for event in model.stream_response(
10931168
filtered.instructions,
@@ -1219,8 +1294,7 @@ async def _run_single_turn(
12191294
run_config: RunConfig,
12201295
should_run_agent_start_hooks: bool,
12211296
tool_use_tracker: AgentToolUseTracker,
1222-
previous_response_id: str | None,
1223-
conversation_id: str | None,
1297+
server_conversation_tracker: _ServerConversationTracker | None = None,
12241298
) -> SingleStepResult:
12251299
# Ensure we run the hooks before anything else
12261300
if should_run_agent_start_hooks:
@@ -1240,8 +1314,11 @@ async def _run_single_turn(
12401314

12411315
output_schema = cls._get_output_schema(agent)
12421316
handoffs = await cls._get_handoffs(agent, context_wrapper)
1243-
input = ItemHelpers.input_to_new_input_list(original_input)
1244-
input.extend([generated_item.to_input_item() for generated_item in generated_items])
1317+
if server_conversation_tracker is not None:
1318+
input = server_conversation_tracker.prepare_input(original_input, generated_items)
1319+
else:
1320+
input = ItemHelpers.input_to_new_input_list(original_input)
1321+
input.extend([generated_item.to_input_item() for generated_item in generated_items])
12451322

12461323
new_response = await cls._get_new_response(
12471324
agent,
@@ -1254,8 +1331,7 @@ async def _run_single_turn(
12541331
context_wrapper,
12551332
run_config,
12561333
tool_use_tracker,
1257-
previous_response_id,
1258-
conversation_id,
1334+
server_conversation_tracker,
12591335
prompt_config,
12601336
)
12611337

@@ -1459,8 +1535,7 @@ async def _get_new_response(
14591535
context_wrapper: RunContextWrapper[TContext],
14601536
run_config: RunConfig,
14611537
tool_use_tracker: AgentToolUseTracker,
1462-
previous_response_id: str | None,
1463-
conversation_id: str | None,
1538+
server_conversation_tracker: _ServerConversationTracker | None,
14641539
prompt_config: ResponsePromptParam | None,
14651540
) -> ModelResponse:
14661541
# Allow user to modify model input right before the call, if configured
@@ -1491,6 +1566,15 @@ async def _get_new_response(
14911566
),
14921567
)
14931568

1569+
previous_response_id = (
1570+
server_conversation_tracker.previous_response_id
1571+
if server_conversation_tracker
1572+
else None
1573+
)
1574+
conversation_id = (
1575+
server_conversation_tracker.conversation_id if server_conversation_tracker else None
1576+
)
1577+
14941578
new_response = await model.get_response(
14951579
system_instructions=filtered.instructions,
14961580
input=filtered.input,

tests/fake_model.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def __init__(
3434
)
3535
self.tracing_enabled = tracing_enabled
3636
self.last_turn_args: dict[str, Any] = {}
37+
self.first_turn_args: dict[str, Any] | None = None
3738
self.hardcoded_usage: Usage | None = None
3839

3940
def set_hardcoded_usage(self, usage: Usage):
@@ -64,7 +65,7 @@ async def get_response(
6465
conversation_id: str | None,
6566
prompt: Any | None,
6667
) -> ModelResponse:
67-
self.last_turn_args = {
68+
turn_args = {
6869
"system_instructions": system_instructions,
6970
"input": input,
7071
"model_settings": model_settings,
@@ -74,6 +75,11 @@ async def get_response(
7475
"conversation_id": conversation_id,
7576
}
7677

78+
if self.first_turn_args is None:
79+
self.first_turn_args = turn_args.copy()
80+
81+
self.last_turn_args = turn_args
82+
7783
with generation_span(disabled=not self.tracing_enabled) as span:
7884
output = self.get_next_output()
7985

@@ -92,7 +98,7 @@ async def get_response(
9298
return ModelResponse(
9399
output=output,
94100
usage=self.hardcoded_usage or Usage(),
95-
response_id=None,
101+
response_id="resp-789",
96102
)
97103

98104
async def stream_response(
@@ -109,7 +115,7 @@ async def stream_response(
109115
conversation_id: str | None = None,
110116
prompt: Any | None = None,
111117
) -> AsyncIterator[TResponseStreamEvent]:
112-
self.last_turn_args = {
118+
turn_args = {
113119
"system_instructions": system_instructions,
114120
"input": input,
115121
"model_settings": model_settings,
@@ -118,6 +124,11 @@ async def stream_response(
118124
"previous_response_id": previous_response_id,
119125
"conversation_id": conversation_id,
120126
}
127+
128+
if self.first_turn_args is None:
129+
self.first_turn_args = turn_args.copy()
130+
131+
self.last_turn_args = turn_args
121132
with generation_span(disabled=not self.tracing_enabled) as span:
122133
output = self.get_next_output()
123134
if isinstance(output, Exception):
@@ -145,7 +156,7 @@ def get_response_obj(
145156
usage: Usage | None = None,
146157
) -> Response:
147158
return Response(
148-
id=response_id or "123",
159+
id=response_id or "resp-789",
149160
created_at=123,
150161
model="test_model",
151162
object="response",

0 commit comments

Comments
 (0)