@@ -181,13 +181,34 @@ def extract_prompty_params(p: Any) -> dict[str, Any]:
181181 return params
182182
183183
184+ def _normalize_metadata_keys (
185+ value : Optional [Iterable [str ]],
186+ ) -> Optional [tuple [str , ...]]:
187+ """Validate + coerce a ``transcript_metadata_keys`` argument to a tuple.
188+
189+ Rejects ``str`` outright (a bare string is iterable char-by-char, which
190+ would silently produce a one-letter allow-list). Returns ``None`` for
191+ empty or missing input.
192+ """
193+ if value is None :
194+ return None
195+ if isinstance (value , str ):
196+ raise TypeError (
197+ "transcript_metadata_keys must be a sequence of keys "
198+ "(list/tuple/set), not a single str. "
199+ f"Got: { value !r} . Did you mean [{ value !r} ]?"
200+ )
201+ keys = tuple (str (k ) for k in value if str (k ))
202+ return keys or None
203+
204+
184205def _format_metadata_segment (
185206 metadata : Any ,
186- metadata_keys : Optional [Iterable [str ]],
207+ metadata_keys : Optional [tuple [str , ... ]],
187208) -> str :
188209 """Render the trailing ``[metadata: {...}]`` segment for a transcript line.
189210
190- Returns an empty string unless ``metadata_keys`` is a non-empty iterable
211+ Returns an empty string unless ``metadata_keys`` is a non-empty tuple
191212 AND at least one of those keys is present in ``metadata``. Only the
192213 explicitly allow-listed keys are serialized, in the iteration order of
193214 ``metadata_keys``.
@@ -197,7 +218,8 @@ def _format_metadata_segment(
197218 filtered = {k : metadata [k ] for k in metadata_keys if k in metadata }
198219 if not filtered :
199220 return ""
200- return f" [metadata: { json .dumps (filtered )} ]"
221+ payload = json .dumps (filtered , separators = ("," , ":" ), ensure_ascii = False , default = str )
222+ return f" [metadata: { payload } ]"
201223
202224
203225def build_transcript (
@@ -227,13 +249,18 @@ def build_transcript(
227249 metadata blobs (raw tool calls, IDE schema, etc.) out of every
228250 prompt — they're often 10-100x larger than the dialog itself and
229251 dilute extraction quality.
252+
253+ Accepts any iterable of strings except ``str`` itself (which would
254+ be interpreted char-by-char). Generators are coerced to a tuple so
255+ the allow-list is reusable across turns.
230256 """
257+ keys = _normalize_metadata_keys (metadata_keys )
231258 if not group_by_thread :
232259 lines : list [str ] = []
233260 for m in items :
234261 role = m .get ("role" , "unknown" )
235262 content = m .get ("content" , "" )
236- meta_str = _format_metadata_segment (m .get ("metadata" , {}), metadata_keys )
263+ meta_str = _format_metadata_segment (m .get ("metadata" , {}), keys )
237264 lines .append (f"[{ role } ]: { content } { meta_str } " )
238265 return "\n " .join (lines )
239266
@@ -247,7 +274,7 @@ def build_transcript(
247274 for m in thread_items :
248275 role = m .get ("role" , "unknown" )
249276 content = m .get ("content" , "" )
250- meta_str = _format_metadata_segment (m .get ("metadata" , {}), metadata_keys )
277+ meta_str = _format_metadata_segment (m .get ("metadata" , {}), keys )
251278 parts .append (f"[{ role } ]: { content } { meta_str } " )
252279 parts .append ("" )
253280 return "\n " .join (parts )
0 commit comments