Prevent Responses metadata history bloat

Persist only durable Responses continuation metadata instead of repeated prompts and raw model payloads. Normalize legacy AI message metadata during load while preserving structured output, unrelated metadata, and tool-result inputs.

Alessandro committed Jul 14, 2026 at 20:41 UTC 7b216c63437973592ee4d75c0d933d4f7493e43a
5 files changed +53 -6
helpers/history.py
+9 -1
@@ -150,11 +150,19 @@ class Message(Record):
150 @staticmethod
151 def from_dict(data: dict, history: "History"):
152 content = data.get("content", "Content lost")
153 + metadata = data.get("metadata", {})
154 + metadata = metadata if isinstance(metadata, dict) else {}
155 + if data["ai"]:
156 + from helpers.llm_result import result_from_metadata
157 +
158 + result = result_from_metadata(metadata)
159 + if result:
160 + metadata = {**metadata, **result.metadata()}
161 msg = Message(
162 ai=data["ai"],
163 content=content,
164 id=data.get("id", ""),
157 - metadata=data.get("metadata", {}) if isinstance(data.get("metadata"), dict) else {},
165 + metadata=metadata,
166 sequence=int(data.get("sequence", 0) or 0),
167 )
168 msg.summary = data.get("summary", "")
helpers/history.py.dox.md
+1
@@ -79,6 +79,7 @@
79 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
80 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
81 - `clear_responses_provider_state(agent)` removes the active provider continuation IDs after local history rewrites while preserving stored response ID lists for later cleanup.
82 +- `Message.from_dict()` normalizes legacy AI Responses metadata through `LLMResult.metadata()` so loaded chats shed transient payloads while unrelated metadata and non-AI tool-result inputs remain intact.
83 - Observed side-effect areas: filesystem writes, filesystem deletion, model calls, plugin state, settings/state persistence, secret handling.
84 - Imported dependency areas include: `abc`, `asyncio`, `collections`, `collections.abc`, `enum`, `helpers`, `json`, `langchain_core.messages`, `math`, `plugins._model_config.helpers.model_config`, `typing`, `uuid`.
85
helpers/llm_result.py
+12 -1
@@ -216,7 +216,18 @@ class LLMResult:
216 }
217
218 def metadata(self) -> dict[str, Any]:
219 - return {RESPONSE_METADATA_KEY: self.to_dict()}
219 + return {
220 + RESPONSE_METADATA_KEY: {
221 + "response_id": self.response_id,
222 + "previous_response_id": self.previous_response_id,
223 + "output_items": [item.to_dict() for item in self.output_items],
224 + "provider_model_key": self.provider_model_key,
225 + "mode": self.mode,
226 + "state": self.state,
227 + "usage": self.usage,
228 + "capability": self.capability,
229 + }
230 + }
231
232
233 def function_call_output_item(
helpers/llm_result.py.dox.md
+1 -1
@@ -17,7 +17,7 @@
17
18 ## Runtime Contracts
19
20 -- `LLMResult.metadata()` stores data under `RESPONSE_METADATA_KEY` so history can round-trip provider state.
20 +- `LLMResult.metadata()` stores only durable provider state under `RESPONSE_METADATA_KEY`: response IDs, structured output items, provider/mode/state, usage, and capability data. Runtime prompt inputs, raw responses, and duplicated response/reasoning text are not persisted in history.
21 - `from_response(...)` must preserve provider `response_id`, `previous_response_id`, raw output items, usage, and capability metadata.
22 - `from_chat(...)` must produce an equivalent chat-completions result with `mode="chat_completions"` and `state="off"`, preserving optional function-call output items when the chat transport supplies them.
23 - Function-call output items must preserve `call_id` and optional acknowledged safety checks.
tests/test_responses_architecture.py
+30 -3
@@ -43,7 +43,7 @@ class _AsyncEventStream:
43 self.closed = True
44
45
46 -def test_llm_result_round_trips_responses_metadata():
46 +def test_llm_result_persists_only_durable_responses_metadata():
47 result = LLMResult.from_response(
48 {
49 "id": "resp_123",
@@ -69,7 +69,14 @@ def test_llm_result_round_trips_responses_metadata():
69 provider_model_key="openai/gpt-5.4",
70 )
71
72 - loaded = result_from_metadata(result.metadata())
72 + metadata = result.metadata()
73 + persisted = metadata["responses"]
74 + assert "response" not in persisted
75 + assert "reasoning" not in persisted
76 + assert "input_items" not in persisted
77 + assert "raw" not in persisted
78 +
79 + loaded = result_from_metadata(metadata)
80
81 assert loaded is not None
82 assert loaded.response_id == "resp_123"
@@ -79,22 +86,42 @@ def test_llm_result_round_trips_responses_metadata():
86 assert loaded.builtin_items[0].type == "web_search_call"
87
88
82 -def test_history_serializes_metadata_and_migrates_old_messages():
89 +def test_history_migrates_legacy_ai_metadata_and_preserves_tool_inputs():
90 class DummyAgent:
91 pass
92
93 hist = history.History(DummyAgent())
94 result = LLMResult.from_response(
95 {"id": "resp_1", "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}]},
96 + input_items=[{"role": "user", "content": "question"}],
97 provider_model_key="openai/gpt-5.4",
98 )
99
100 message = hist.add_message(True, "ok", metadata=result.metadata())
101 + tool_item = {"type": "function_call_output", "call_id": "call_1", "output": "done"}
102 + hist.add_message(
103 + False,
104 + "done",
105 + metadata={"responses": {"input_items": [tool_item]}},
106 + )
107 restored = history.deserialize_history(hist.serialize(), DummyAgent())
108
109 restored_message = restored.all_messages()[0]
110 assert restored_message.sequence == message.sequence
111 assert result_from_metadata(restored_message.metadata).response_id == "resp_1"
112 + assert restored.all_messages()[1].metadata["responses"]["input_items"] == [tool_item]
113 +
114 + migrated = history.Message.from_dict(
115 + {
116 + "_cls": "Message",
117 + "ai": True,
118 + "content": "old",
119 + "metadata": {"custom": "keep", "responses": result.to_dict()},
120 + },
121 + restored,
122 + )
123 + assert "input_items" not in migrated.metadata["responses"]
124 + assert migrated.metadata["custom"] == "keep"
125
126 old = history.Message.from_dict({"_cls": "Message", "ai": False, "content": "old"}, restored)
127 assert old.metadata == {}