Skip to content
Merged
1 change: 1 addition & 0 deletions contributors/emails/chenyang.yl@alibaba-inc.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LiangYang666
1 change: 1 addition & 0 deletions contributors/emails/mail.liangyang@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LiangYang666
56 changes: 52 additions & 4 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3310,6 +3310,7 @@ def _persist_response_snapshot(
response_env: Dict[str, Any],
*,
conversation_history_snapshot: Optional[List[Dict[str, Any]]] = None,
session_id_snapshot: Optional[str] = None,
) -> None:
if not store:
return
Expand All @@ -3320,7 +3321,7 @@ def _persist_response_snapshot(
"response": response_env,
"conversation_history": conversation_history_snapshot,
"instructions": instructions,
"session_id": session_id,
"session_id": session_id_snapshot or session_id,
})
if conversation:
self._response_store.set_conversation(conversation, response_id)
Expand Down Expand Up @@ -3723,9 +3724,15 @@ async def _flush_batch() -> None:
result,
final_response_text,
)
# Compression-aware transcript substitution happens inside
# _build_response_conversation_history (result["_compressed"]);
# here we only propagate a compression-rotated session_id so
# previous_response_id chaining resumes the child session.
_result_sid = result.get("session_id") if isinstance(result, dict) else None
_persist_response_snapshot(
completed_env,
conversation_history_snapshot=full_history,
session_id_snapshot=_result_sid if isinstance(_result_sid, str) and _result_sid else None,
)
terminal_snapshot_persisted = True
await _write_event("response.completed", {
Expand Down Expand Up @@ -4038,6 +4045,16 @@ async def _compute_response():
final_response,
)

# Persist the effective session ID surfaced by _run_agent so that
# compression-triggered session rotations propagate to the stored
# response and the X-Hermes-Session-Id header. Without this,
# previous_response_id chaining keeps resuming the pre-rotation
# session and re-triggers compression on every subsequent request.
_effective_session_id = session_id
_result_sid = result.get("session_id") if isinstance(result, dict) else None
if isinstance(_result_sid, str) and _result_sid:
_effective_session_id = _result_sid

# Build output items from the current turn only. AIAgent returns a
# full transcript in result["messages"], while older/mocked paths may
# return only the current turn suffix.
Expand Down Expand Up @@ -4068,14 +4085,14 @@ async def _compute_response():
"response": response_data,
"conversation_history": full_history,
"instructions": instructions,
"session_id": session_id,
"session_id": _effective_session_id,
})
# Update conversation mapping so the next request with the same
# conversation name automatically chains to this response
if conversation:
self._response_store.set_conversation(conversation, response_id)

response_headers = {"X-Hermes-Session-Id": session_id}
response_headers = {"X-Hermes-Session-Id": _effective_session_id}
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
return web.json_response(response_data, headers=response_headers)
Expand Down Expand Up @@ -4432,7 +4449,16 @@ def _build_response_conversation_history(
result: Dict[str, Any],
final_response: Any,
) -> List[Dict[str, Any]]:
"""Build the stored Responses transcript without duplicating history."""
"""Build the stored Responses transcript without duplicating history.

When context compression occurs during a turn the agent returns a
compressed full transcript in ``result["messages"]`` (starting with a
summary) and sets ``result["_compressed"] = True``. Because the
compressed transcript does not share the input ``conversation_history``
prefix, the normal turn-start detection fails and old code would
concatenate the uncompressed history on front, bloating the stored
context and re-triggering compression on every subsequent request.
"""
prior = list(conversation_history)
current_user = {"role": "user", "content": user_message}
agent_messages = result.get("messages") if isinstance(result, dict) else None
Expand All @@ -4446,6 +4472,16 @@ def _build_response_conversation_history(
if turn_start:
return list(agent_messages)

# turn_start == 0: agent_messages does not start with prior.
# This can happen because compression rewrote the transcript
# (summary prefix replaces original history), OR because
# agent_messages only carries the current turn without prior.
# The ``_compressed`` flag (set by _run_agent after compaction)
# distinguishes — skip the concatenation and use the compressed
# transcript directly.
if result.get("_compressed"):
return list(agent_messages)

full_history = prior
full_history.append(current_user)
full_history.extend(agent_messages)
Expand Down Expand Up @@ -4706,6 +4742,18 @@ def _run():
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
# Signal whether context compression occurred during this turn
# so _build_response_conversation_history can skip the
# prior-concatenation path and store the compressed transcript
# directly. Rotation mode changes agent.session_id; in-place
# mode sets _last_compaction_in_place (see #38763).
_compacted_in_place = bool(getattr(agent, "_last_compaction_in_place", False))
_session_rotated = (
isinstance(_eff_sid, str) and isinstance(session_id, str)
and _eff_sid != session_id
)
if _compacted_in_place or _session_rotated:
result["_compressed"] = True
return result, usage
finally:
clear_session_vars(tokens)
Expand Down
267 changes: 267 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,273 @@ async def test_previous_response_id_stores_full_agent_transcript_once(self, adap
assert stored_history.count(first_history[0]) == 1
assert stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1

@pytest.mark.asyncio
async def test_previous_response_id_stores_compressed_transcript_directly(self, adapter):
"""After compression, stored history is the compressed transcript, not prior + compressed."""
prior_history = [
{"role": "user", "content": "What is 1+1?"},
{"role": "assistant", "content": "2"},
] * 10 # 20 messages — enough to simulate a long conversation
adapter._response_store.put(
"resp_prev",
{
"response": {"id": "resp_prev", "status": "completed"},
"conversation_history": list(prior_history),
"session_id": "api-test-session",
},
)

compressed_history = [
# Compressed transcript starts with summary, NOT with prior[0]
{"role": "user", "content": "[Compressed summary of earlier conversation]"},
{"role": "user", "content": "Now add 1 more"},
{"role": "assistant", "content": "3"},
]

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{
"final_response": "3",
"messages": list(compressed_history),
"_compressed": True,
"api_calls": 1,
},
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Now add 1 more",
"previous_response_id": "resp_prev",
},
)
assert resp.status == 200
data = await resp.json()

stored = adapter._response_store.get(data["id"])
stored_history = stored["conversation_history"]
# Must NOT contain the original prior_history messages
for msg in prior_history:
assert msg not in stored_history, (
f"Prior history message leaked into stored compressed transcript: {msg}"
)
# Must contain the compressed transcript
assert stored_history == compressed_history

@pytest.mark.asyncio
async def test_rotation_compression_exercises_detection_and_persists_rotated_session_id(
self, adapter
):
"""Fake-agent rotation: _run_agent detects session_id change, sets
_compressed, and the Responses handler persists the rotated session_id
so subsequent previous_response_id chaining loads the compressed child."""
prior_history = [
{"role": "user", "content": "What is 1+1?"},
{"role": "assistant", "content": "2"},
] * 10

adapter._response_store.put(
"resp_prev_rot",
{
"response": {"id": "resp_prev_rot", "status": "completed"},
"conversation_history": list(prior_history),
"session_id": "api-test-session",
},
)

compressed_history = [
{"role": "user", "content": "[Compressed summary]"},
{"role": "user", "content": "Now add 1 more"},
{"role": "assistant", "content": "3"},
]

# Fake agent whose session_id was rotated by compression
mock_agent = MagicMock()
mock_agent.session_id = "rotated-child-session"
mock_agent._last_compaction_in_place = False
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
mock_agent.run_conversation.return_value = {
"final_response": "3",
"messages": list(compressed_history),
"api_calls": 1,
}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent", return_value=mock_agent):
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Now add 1 more",
"previous_response_id": "resp_prev_rot",
},
)
assert resp.status == 200
data = await resp.json()

# The detection path in _run_agent must have set _compressed
# _run_agent mutates the result dict in place -- verify via stored data
stored = adapter._response_store.get(data["id"])
assert stored is not None

# Stored history must be the compressed transcript, not prior + compressed
stored_history = stored["conversation_history"]
for msg in prior_history:
assert msg not in stored_history
assert stored_history == compressed_history

# Stored session_id must be the rotated child, not the original
assert stored["session_id"] == "rotated-child-session"

# Response header must also reflect the rotated session
assert resp.headers.get("X-Hermes-Session-Id") == "rotated-child-session"

@pytest.mark.asyncio
async def test_inplace_compression_exercises_detection_and_persists_compressed_history(
self, adapter
):
"""Fake-agent in-place: _run_agent detects _last_compaction_in_place,
sets _compressed, and the Responses handler persists compressed history
WITHOUT rotating the session_id."""
prior_history = [
{"role": "user", "content": "What is 1+1?"},
{"role": "assistant", "content": "2"},
] * 10

adapter._response_store.put(
"resp_prev_inplace",
{
"response": {"id": "resp_prev_inplace", "status": "completed"},
"conversation_history": list(prior_history),
"session_id": "api-test-session",
},
)

compressed_history = [
{"role": "user", "content": "[Compressed summary in-place]"},
{"role": "user", "content": "Continue"},
{"role": "assistant", "content": "42"},
]

# Fake agent: in-place compaction, session_id unchanged
mock_agent = MagicMock()
mock_agent.session_id = "api-test-session" # same as input -- no rotation
mock_agent._last_compaction_in_place = True
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
mock_agent.run_conversation.return_value = {
"final_response": "42",
"messages": list(compressed_history),
"api_calls": 1,
}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent", return_value=mock_agent):
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Continue",
"previous_response_id": "resp_prev_inplace",
},
)
assert resp.status == 200
data = await resp.json()

stored = adapter._response_store.get(data["id"])
assert stored is not None

# Stored history must be the compressed transcript
stored_history = stored["conversation_history"]
for msg in prior_history:
assert msg not in stored_history
assert stored_history == compressed_history

# Session_id must NOT change for in-place compaction
assert stored["session_id"] == "api-test-session"
assert resp.headers.get("X-Hermes-Session-Id") == "api-test-session"

@pytest.mark.asyncio
async def test_chained_rotation_propagates_effective_session_id(self, adapter):
"""Two-request chain: first request triggers rotation, second request
loads history using the rotated session_id stored by the first."""
# First request -- no previous_response_id, establishes the conversation
mock_agent_1 = MagicMock()
mock_agent_1.session_id = "child-session-after-rotation"
mock_agent_1._last_compaction_in_place = False
mock_agent_1.session_prompt_tokens = 0
mock_agent_1.session_completion_tokens = 0
mock_agent_1.session_total_tokens = 0
compressed_msg_1 = [
{"role": "user", "content": "[Summary of turn 1]"},
{"role": "assistant", "content": "Hello back"},
]
mock_agent_1.run_conversation.return_value = {
"final_response": "Hello back",
"messages": list(compressed_msg_1),
"api_calls": 1,
}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent", return_value=mock_agent_1):
resp1 = await cli.post(
"/v1/responses",
json={"model": "hermes-agent", "input": "Hello"},
)
assert resp1.status == 200
data1 = await resp1.json()
response_id_1 = data1["id"]

# Verify the rotated session_id was persisted
stored_1 = adapter._response_store.get(response_id_1)
assert stored_1["session_id"] == "child-session-after-rotation"
assert stored_1["conversation_history"] == compressed_msg_1

# Second request -- chains via previous_response_id
# _run_agent is called with the session_id from the stored response
mock_agent_2 = MagicMock()
mock_agent_2.session_id = "child-session-after-rotation"
mock_agent_2._last_compaction_in_place = False
mock_agent_2.session_prompt_tokens = 0
mock_agent_2.session_completion_tokens = 0
mock_agent_2.session_total_tokens = 0
mock_agent_2.run_conversation.return_value = {
"final_response": "Goodbye",
"messages": [
{"role": "user", "content": "[Summary of turn 1]"},
{"role": "assistant", "content": "Hello back"},
{"role": "user", "content": "Goodbye"},
{"role": "assistant", "content": "See you!"},
],
"api_calls": 1,
}

with patch.object(adapter, "_create_agent", return_value=mock_agent_2):
resp2 = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Goodbye",
"previous_response_id": response_id_1,
},
)
assert resp2.status == 200

# The second _run_agent call must receive the rotated session_id
# (from the stored response), not the original request session_id
call_kwargs = mock_agent_2.run_conversation.call_args.kwargs
# conversation_history loaded from store should be the compressed transcript
assert call_kwargs["conversation_history"] == compressed_msg_1

@pytest.mark.asyncio
async def test_previous_response_id_outputs_only_current_turn_items(self, adapter):
"""Response output must not replay previous tool artifacts."""
Expand Down
Loading