Skip to content

Commit b56dd0b

Browse files
Fix response internal metadata persistence (#48590)
* Fix response internal metadata persistence Restore compact JSON-string encoding for response-level internal metadata so Foundry storage accepts and round-trips checkpoint state. Normalize nested-dictionary snapshots and enforce metadata service limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bdc32ee9-9e8f-4a1c-ba27-bdfa318afa1e * Fix Responses changelog validation Move the internal metadata bug fix into the current 2.1.0b1 release entry so changelog validation uses the latest dated version and finds a populated Bugs Fixed section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bdc32ee9-9e8f-4a1c-ba27-bdfa318afa1e --------- Copilot-Session: bdc32ee9-9e8f-4a1c-ba27-bdfa318afa1e
1 parent 63e99dd commit b56dd0b

10 files changed

Lines changed: 188 additions & 43 deletions

File tree

‎sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
applications now persist cross-turn state explicitly with
2020
`FoundryStateStore`.
2121

22+
### Bugs Fixed
23+
24+
- Restored JSON-string encoding for response-level `internal_metadata` so resilient response checkpoints round-trip through Foundry storage.
25+
2226
### Other Changes
2327

2428
- Updated the resilient Responses samples to use conversation-scoped

‎sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/streaming/_event_stream.py‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from ._state_machine import EventStreamValidator
3232
from ._checkpoint import ResponseCheckpointEvent
33+
from ._internal_metadata import _ResponseInternalMetadataView
3334

3435
# Event types whose payload is a full Response snapshot.
3536
# Lifecycle events nest under a "response" key on the wire.
@@ -176,6 +177,7 @@ def __init__(
176177
if agent_reference is not None:
177178
self._response["agent_reference"] = deepcopy(agent_reference)
178179

180+
_ResponseInternalMetadataView(self._response)
179181
self._agent_reference, self._model = _internals.extract_response_fields(
180182
cast(response_models.ResponseObject, self._response)
181183
)
@@ -198,23 +200,16 @@ def internal_metadata(self) -> "MutableMapping[str, Any]":
198200
"""Live, mutable response-level framework-internal metadata.
199201
200202
A convenience proxy backed by a reserved ``_internal_metadata`` key
201-
inside the response's public ``metadata`` map — read / write / delete in
202-
place (``stream.internal_metadata["phase"] = 3``). Stripped from every
203-
client-facing payload and persisted at the next ``yield
204-
stream.checkpoint()`` (and at terminal). Values may be any
205-
JSON-serialisable type.
203+
inside the response's public ``metadata`` map. The private bag is
204+
JSON-encoded into the string-valued metadata slot required by Foundry
205+
storage. Read / write / delete in place
206+
(``stream.internal_metadata["phase"] = 3``). Stripped from every
207+
client-facing payload and persisted at the next
208+
``yield stream.checkpoint()`` (and at terminal).
206209
207210
:rtype: ~collections.abc.MutableMapping[str, ~typing.Any]
208211
"""
209-
metadata = self._response.get("metadata")
210-
if not isinstance(metadata, dict):
211-
metadata = {}
212-
self._response["metadata"] = metadata
213-
bag = metadata.get("_internal_metadata")
214-
if not isinstance(bag, dict):
215-
bag = {}
216-
metadata["_internal_metadata"] = bag
217-
return bag
212+
return _ResponseInternalMetadataView(self._response)
218213

219214
def checkpoint(self) -> "ResponseCheckpointEvent":
220215
"""Return a checkpoint event to ``yield`` for persistence.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
"""Response-level internal metadata backed by the public metadata map."""
4+
5+
from __future__ import annotations
6+
7+
import json
8+
from collections.abc import Iterator, MutableMapping
9+
from typing import Any
10+
11+
_RESERVED_KEY = "_internal_metadata"
12+
_MAX_METADATA_KEYS = 16
13+
_MAX_VALUE_LEN = 512
14+
15+
16+
class _ResponseInternalMetadataView(MutableMapping[str, Any]):
17+
"""Live view over JSON-encoded response-level internal metadata."""
18+
19+
__slots__ = ("_response",)
20+
21+
def __init__(self, response: dict[str, Any]) -> None:
22+
self._response = response
23+
metadata = response.get("metadata")
24+
if isinstance(metadata, dict) and isinstance(metadata.get(_RESERVED_KEY), dict):
25+
self._store(dict(metadata[_RESERVED_KEY]))
26+
27+
def _decode(self) -> dict[str, Any]:
28+
metadata = self._response.get("metadata")
29+
if not isinstance(metadata, dict):
30+
return {}
31+
raw = metadata.get(_RESERVED_KEY)
32+
if isinstance(raw, dict):
33+
return dict(raw)
34+
if not isinstance(raw, str) or not raw:
35+
return {}
36+
try:
37+
decoded = json.loads(raw)
38+
except (TypeError, ValueError):
39+
return {}
40+
return decoded if isinstance(decoded, dict) else {}
41+
42+
def _store(self, value: dict[str, Any]) -> None:
43+
metadata = self._response.get("metadata")
44+
if not value:
45+
if isinstance(metadata, dict):
46+
metadata.pop(_RESERVED_KEY, None)
47+
return
48+
49+
encoded = json.dumps(
50+
value,
51+
ensure_ascii=False,
52+
separators=(",", ":"),
53+
sort_keys=True,
54+
)
55+
if len(encoded) > _MAX_VALUE_LEN:
56+
raise ValueError(
57+
f"internal_metadata encodes to {len(encoded)} chars, exceeding the "
58+
f"{_MAX_VALUE_LEN}-char limit of the response metadata value"
59+
)
60+
61+
if not isinstance(metadata, dict):
62+
metadata = {}
63+
self._response["metadata"] = metadata
64+
projected_key_count = len(metadata) + (0 if _RESERVED_KEY in metadata else 1)
65+
if projected_key_count > _MAX_METADATA_KEYS:
66+
raise ValueError(
67+
f"cannot add internal_metadata: response metadata already has "
68+
f"{len(metadata)} keys (limit {_MAX_METADATA_KEYS})"
69+
)
70+
metadata[_RESERVED_KEY] = encoded
71+
72+
def __getitem__(self, key: str) -> Any:
73+
return self._decode()[key]
74+
75+
def __setitem__(self, key: str, value: Any) -> None:
76+
if not isinstance(key, str):
77+
raise TypeError(f"internal_metadata keys must be str, got {type(key).__name__}")
78+
metadata = self._decode()
79+
metadata[key] = value
80+
self._store(metadata)
81+
82+
def __delitem__(self, key: str) -> None:
83+
metadata = self._decode()
84+
del metadata[key]
85+
self._store(metadata)
86+
87+
def __iter__(self) -> Iterator[str]:
88+
return iter(self._decode())
89+
90+
def __len__(self) -> int:
91+
return len(self._decode())

‎sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,13 +1529,21 @@ stream.internal_metadata["resume_phase"] = 3
15291529
del stream.internal_metadata["scratch"]
15301530
```
15311531

1532+
Response-level values may be any JSON-serializable type, but the complete bag
1533+
is encoded into one string-valued reserved metadata entry. Its compact JSON
1534+
representation must fit the Foundry metadata value limit of 512 characters,
1535+
and the reserved entry consumes one of the response metadata map's 16 keys.
1536+
Item-level internal metadata is stored directly on the output item and does not
1537+
use those response metadata limits.
1538+
15321539
Use it for lightweight per-turn watermarks, id mappings (e.g. an upstream
15331540
framework's message id ↔ the emitted item), or stale-message / crash-recovery
15341541
detection within the turn. It is persisted whenever the response is persisted —
15351542
at `response.created`, at each `yield stream.checkpoint()`, and at terminal — so
1536-
on recovery you read it back from `context.persisted_response`. It is distinct
1537-
from the *public* `ResponseObject.metadata` dict (the client's own metadata,
1538-
which is NOT stripped).
1543+
on recovery, seed `ResponseEventStream(response=context.persisted_response, ...)`
1544+
and read it through `stream.internal_metadata`. It is distinct from the *public*
1545+
`ResponseObject.metadata` dict (the client's own metadata, which is NOT
1546+
stripped).
15391547

15401548
### Which state facility?
15411549

‎sdk/agentserver/azure-ai-agentserver-responses/docs/responses-resilience-spec.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,10 @@ client-facing HTTP/SSE payload** — and symmetrically stripped on ingress, so
755755
clients can neither read nor inject it. Use it for lightweight per-turn
756756
watermarks, id mappings (upstream message id ↔ emitted item), or in-turn
757757
stale-message detection; read it back on recovery via
758-
`context.persisted_response`. It is distinct from the *public*
758+
`ResponseEventStream(response=context.persisted_response, ...)`. Response-level
759+
internal metadata is compact-JSON encoded into one string-valued reserved
760+
metadata entry and must fit that entry's 512-character limit. It is distinct
761+
from the *public*
759762
`ResponseObject.metadata` (the client's own metadata, never stripped) and
760763
from `FoundryStateStore` (cross-turn application state — §8.1).
761764
Rule of thumb: cross-turn state → `FoundryStateStore`; reconstruct

‎sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_checkpoint.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import asyncio
14+
import json
1415
import time
1516
from typing import Any
1617

@@ -42,8 +43,8 @@ async def update_response(self, response, *, context=None): # noqa: ANN001
4243

4344
def _event(**md) -> ResponseCheckpointEvent:
4445
resp: ResponseObject = {"id": "r1", "object": "response", "status": "in_progress", "output": [], "model": "m"}
45-
for k, v in md.items():
46-
resp.setdefault("metadata", {}).setdefault("_internal_metadata", {})[k] = v
46+
if md:
47+
resp["metadata"] = {"_internal_metadata": json.dumps(md, separators=(",", ":"), sort_keys=True)}
4748
return ResponseCheckpointEvent(resp)
4849

4950

@@ -165,7 +166,7 @@ async def test_t21_status_as_is_in_snapshot():
165166
)
166167
assert p.updates[0]["status"] == "in_progress"
167168
# Reserved internal_metadata is in the persisted snapshot (storage retains it).
168-
assert p.updates[0]["metadata"]["_internal_metadata"] == {"cp": 1}
169+
assert p.updates[0]["metadata"]["_internal_metadata"] == '{"cp":1}'
169170

170171

171172
@pytest.mark.asyncio

‎sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717

1818
from azure.ai.agentserver.responses._response_context import PlatformContext
19+
from azure.ai.agentserver.responses import ResponseEventStream
1920
from azure.ai.agentserver.responses.store._foundry_errors import (
2021
FoundryApiError,
2122
FoundryBadRequestError,
@@ -167,12 +168,16 @@ async def test_get_response__gets_correct_url(credential: Any, settings: Foundry
167168

168169
@pytest.mark.asyncio
169170
async def test_get_response__returns_deserialized_response(credential: Any, settings: FoundryStorageSettings) -> None:
170-
provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT))
171+
response = dict(_RESPONSE_DICT)
172+
response["metadata"] = {"_internal_metadata": '{"checkpoint_marker":"written-before-checkpoint"}'}
173+
provider = _make_provider(credential, settings, _make_response(200, response))
171174

172175
result = await provider.get_response("resp_abc123")
173176

174177
assert result["id"] == "resp_abc123"
175178
assert result["status"] == "completed"
179+
stream = ResponseEventStream(response_id="resp_abc123", response=result)
180+
assert dict(stream.internal_metadata) == {"checkpoint_marker": "written-before-checkpoint"}
176181

177182

178183
@pytest.mark.asyncio
@@ -218,11 +223,13 @@ async def test_update_response__sends_serialized_response_body(
218223
) -> None:
219224
provider = _make_provider(credential, settings, _make_response(200, {}))
220225
response = _response_object()
226+
response["metadata"] = {"_internal_metadata": '{"checkpoint_marker":"written-before-checkpoint"}'}
221227
await provider.update_response(response)
222228

223229
request = provider._client.send_request.call_args[0][0]
224230
payload = json.loads(request.content.decode("utf-8"))
225231
assert payload["id"] == "resp_abc123"
232+
assert payload["metadata"]["_internal_metadata"] == '{"checkpoint_marker":"written-before-checkpoint"}'
226233

227234

228235
@pytest.mark.asyncio

‎sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_internal_metadata.py‎

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
from collections.abc import MutableMapping
1213
from copy import deepcopy
1314
from typing import Any, cast
1415

@@ -17,6 +18,7 @@
1718
from azure.ai.agentserver.responses._egress import strip_internal_metadata
1819
from azure.ai.agentserver.responses import CreateResponse, ResponseEventStream
1920
from azure.ai.agentserver.responses.models._generated import ResponseObject
21+
from azure.ai.agentserver.responses.streaming._internal_metadata import _ResponseInternalMetadataView
2022

2123

2224
def _item() -> dict[str, Any]:
@@ -34,9 +36,8 @@ def _item_internal_metadata(item: dict[str, Any]) -> dict[str, Any]:
3436
return item.setdefault("internal_metadata", {})
3537

3638

37-
def _response_internal_metadata(response: ResponseObject) -> dict[str, Any]:
38-
metadata = response.setdefault("metadata", {})
39-
return metadata.setdefault("_internal_metadata", {})
39+
def _response_internal_metadata(response: ResponseObject) -> MutableMapping[str, Any]:
40+
return _ResponseInternalMetadataView(cast(dict[str, Any], response))
4041

4142

4243
# --------------------------------------------------------------------------
@@ -150,8 +151,8 @@ def test_t1r_response_empty_view_when_unset():
150151
def test_t2r_response_stores_under_reserved_key():
151152
resp = _response()
152153
_response_internal_metadata(resp)["phase"] = 3
153-
assert resp["metadata"]["_internal_metadata"] == {"phase": 3}
154-
assert dict(resp["metadata"]["_internal_metadata"]) == {"phase": 3}
154+
assert resp["metadata"]["_internal_metadata"] == '{"phase":3}'
155+
assert dict(_response_internal_metadata(resp)) == {"phase": 3}
155156

156157

157158
def test_t3r_in_place_mutation_writes_through():
@@ -173,15 +174,25 @@ def test_t4r_does_not_clobber_client_metadata():
173174
def test_t5r_clear_removes_only_reserved_key():
174175
resp = _response()
175176
resp["metadata"] = {"user": "x"}
176-
_response_internal_metadata(resp)["phase"] = 3
177-
resp["metadata"].pop("_internal_metadata")
177+
metadata = _response_internal_metadata(resp)
178+
metadata["phase"] = 3
179+
metadata.clear()
178180
assert dict(resp["metadata"]) == {"user": "x"}
179181

180182

181183
def test_t6r_512_char_guard():
182184
resp = _response()
183-
_response_internal_metadata(resp)["big"] = "x" * 600
184-
assert resp["metadata"]["_internal_metadata"]["big"] == "x" * 600
185+
with pytest.raises(ValueError, match="512-char limit"):
186+
_response_internal_metadata(resp)["big"] = "x" * 600
187+
assert "_internal_metadata" not in resp.get("metadata", {})
188+
189+
190+
def test_t6r_unicode_uses_character_limit_not_ascii_escape_length():
191+
resp = _response()
192+
value = "\N{SNOWMAN}" * 100
193+
_response_internal_metadata(resp)["unicode"] = value
194+
assert len(resp["metadata"]["_internal_metadata"]) < 512
195+
assert dict(_response_internal_metadata(resp)) == {"unicode": value}
185196

186197

187198
def test_t6r2_16_key_guard():
@@ -192,8 +203,9 @@ def test_t6r2_16_key_guard():
192203

193204
resp16 = _response()
194205
resp16["metadata"] = {f"k{i}": "v" for i in range(16)}
195-
_response_internal_metadata(resp16)["p"] = 1
196-
assert resp16["metadata"]["_internal_metadata"] == {"p": 1}
206+
with pytest.raises(ValueError, match="already has 16 keys"):
207+
_response_internal_metadata(resp16)["p"] = 1
208+
assert "_internal_metadata" not in resp16["metadata"]
197209

198210

199211
def test_t7r_v_shaped_response_empty_view():
@@ -207,23 +219,43 @@ def test_t10r_stream_proxy_is_response_view():
207219
req = CreateResponse({"model": "m", "input": "hi"})
208220
stream = ResponseEventStream(response_id="resp_1", request=req)
209221
stream.internal_metadata["phase"] = 3
210-
assert dict(stream.response["metadata"]["_internal_metadata"]) == {"phase": 3}
211-
stream.response["metadata"]["_internal_metadata"]["x"] = 1
222+
assert stream.response["metadata"]["_internal_metadata"] == '{"phase":3}'
223+
second_view = _ResponseInternalMetadataView(stream.response)
224+
second_view["x"] = 1
212225
assert stream.internal_metadata["x"] == 1
213226

214227

215228
def test_t28d_response_reserved_key_roundtrips():
216229
resp = _response()
217230
_response_internal_metadata(resp)["phase"] = 3
218231
reloaded = dict(resp)
219-
assert dict(reloaded["metadata"]["_internal_metadata"]) == {"phase": 3}
220-
assert reloaded["metadata"]["_internal_metadata"] == {"phase": 3}
232+
assert reloaded["metadata"]["_internal_metadata"] == '{"phase":3}'
233+
assert dict(ResponseEventStream(response_id="resp_1", response=reloaded).internal_metadata) == {"phase": 3}
221234

222235

223236
def test_t7a_compact_deterministic_encoding():
224237
# Deterministic so checkpoint idempotency byte-compare is stable.
225238
resp = _response()
226239
_response_internal_metadata(resp)["b"] = 2
227-
resp["metadata"]["_internal_metadata"]["a"] = 1
240+
_response_internal_metadata(resp)["a"] = 1
241+
assert resp["metadata"]["_internal_metadata"] == '{"a":1,"b":2}'
228242
stripped = strip_internal_metadata(deepcopy(resp))
229243
assert stripped["metadata"] is None
244+
245+
246+
def test_nested_dict_snapshot_is_normalized_without_property_access():
247+
resp = _response()
248+
resp["metadata"] = {"user": "x", "_internal_metadata": {"phase": 3}}
249+
250+
stream = ResponseEventStream(response_id="resp_1", response=resp)
251+
252+
assert stream.response["metadata"] == {"user": "x", "_internal_metadata": '{"phase":3}'}
253+
254+
255+
def test_nested_dict_snapshot_respects_total_metadata_key_limit():
256+
resp = _response()
257+
resp["metadata"] = {f"k{i}": "v" for i in range(16)}
258+
resp["metadata"]["_internal_metadata"] = {"phase": 3}
259+
260+
with pytest.raises(ValueError, match="already has 17 keys"):
261+
ResponseEventStream(response_id="resp_1", response=resp)

‎sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_internal_metadata_egress.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def test_t17a_t17r_live_objects_untouched_after_sse_encode():
116116
# Encode a terminal carrying the full envelope.
117117
encode_sse_event(stream.emit_completed())
118118
# T17r: live response still carries the reserved key.
119-
assert dict(stream.response["metadata"]["_internal_metadata"]) == {"cp": 3}
119+
assert stream.response["metadata"]["_internal_metadata"] == '{"cp":3}'
120120
# T17a: live output item still carries its bag.
121121
assert dict(stream.response["output"][0]["internal_metadata"]) == {"phase": "gather"}
122122

0 commit comments

Comments
 (0)