@@ -27,9 +27,12 @@ use futures::{Stream, StreamExt};
2727use reloaded_code_core:: hooks:: {
2828 RunEvent , RunMessage , RunMessageRole , RunToolCallSummary , RunToolResultSummary ,
2929} ;
30- use serdes_ai:: core:: messages:: { RetryContent , ToolCallArgs , ToolReturnContent } ;
30+ use serdes_ai:: core:: messages:: {
31+ AudioContent , DocumentContent , FileContent , ImageContent , RetryContent , ToolCallArgs ,
32+ ToolReturnContent , VideoContent ,
33+ } ;
3134use serdes_ai:: core:: {
32- ModelRequest , ModelRequestPart , ModelResponse , ModelResponsePart , UserContent ,
35+ ModelRequest , ModelRequestPart , ModelResponse , ModelResponsePart , UserContent , UserContentPart ,
3336} ;
3437use serdes_ai:: { AgentStream , AgentStreamEvent } ;
3538use std:: pin:: Pin ;
@@ -301,17 +304,48 @@ fn tool_result_message(tool_call_id: Option<String>, output: String) -> RunMessa
301304
302305/// Renders user prompt content as audit text.
303306///
304- /// Plain text passes through; multi-part prompts are serialized so image
305- /// and mixed content stay observable in the transcript.
307+ /// Plain text passes through; multi-part prompts are serialized so
308+ /// image and mixed content stay observable in the transcript. Inline
309+ /// binary parts collapse to a media type plus byte length placeholder
310+ /// so payloads never bloat the transcript.
306311fn user_content_text ( content : UserContent ) -> String {
307312 match content {
308313 UserContent :: Text ( text) => text,
309314 UserContent :: Parts ( parts) => {
310- serde_json:: to_string ( & parts) . unwrap_or_else ( |_| format ! ( "{parts:?}" ) )
315+ // After the map: binary parts are placeholders; text and
316+ // URL parts serialize exactly as before.
317+ let redacted: Vec < _ > = parts. iter ( ) . map ( redact_binary_part) . collect ( ) ;
318+ serde_json:: to_string ( & redacted) . unwrap_or_else ( |_| format ! ( "{redacted:?}" ) )
311319 }
312320 }
313321}
314322
323+ /// Serializes one prompt part for the audit trail.
324+ ///
325+ /// Text and URL parts keep their JSON shape; binary parts become a
326+ /// placeholder carrying their media type and byte length.
327+ fn redact_binary_part ( part : & UserContentPart ) -> serde_json:: Value {
328+ let placeholder = |kind : & str , media_type : & str , bytes : usize | serde_json:: json!( { "type" : kind, "media_type" : media_type, "bytes" : bytes } ) ;
329+ match part {
330+ UserContentPart :: Image {
331+ image : ImageContent :: Binary ( binary) ,
332+ } => placeholder ( "image" , binary. media_type . mime_type ( ) , binary. data . len ( ) ) ,
333+ UserContentPart :: Audio {
334+ audio : AudioContent :: Binary ( binary) ,
335+ } => placeholder ( "audio" , binary. media_type . mime_type ( ) , binary. data . len ( ) ) ,
336+ UserContentPart :: Video {
337+ video : VideoContent :: Binary ( binary) ,
338+ } => placeholder ( "video" , binary. media_type . mime_type ( ) , binary. data . len ( ) ) ,
339+ UserContentPart :: Document {
340+ document : DocumentContent :: Binary ( binary) ,
341+ } => placeholder ( "document" , binary. media_type . mime_type ( ) , binary. data . len ( ) ) ,
342+ UserContentPart :: File {
343+ file : FileContent :: Binary ( binary) ,
344+ } => placeholder ( "file" , binary. mime_type . as_str ( ) , binary. data . len ( ) ) ,
345+ other => serde_json:: to_value ( other) . unwrap_or ( serde_json:: Value :: Null ) ,
346+ }
347+ }
348+
315349#[ cfg( test) ]
316350mod tests {
317351 use super :: * ;
@@ -591,6 +625,41 @@ mod tests {
591625 ) ;
592626 }
593627
628+ #[ test]
629+ fn user_content_text_redacts_binary_but_keeps_text_and_urls ( ) {
630+ use serdes_ai:: core:: messages:: ImageMediaType ;
631+
632+ // Base64 of the binary bytes must never reach the audit text.
633+ let content = UserContent :: parts ( vec ! [
634+ UserContentPart :: text( "look at this" ) ,
635+ UserContentPart :: image_url( "https://example.invalid/image.png" ) ,
636+ UserContentPart :: image_binary( vec![ 1 , 2 , 3 , 4 ] , ImageMediaType :: Png ) ,
637+ UserContentPart :: File {
638+ file: FileContent :: binary( vec![ 9 , 9 ] , "application/pdf" ) ,
639+ } ,
640+ ] ) ;
641+
642+ let rendered = user_content_text ( content) ;
643+
644+ assert ! (
645+ rendered. contains( "look at this" )
646+ && rendered. contains( "https://example.invalid/image.png" ) ,
647+ "text and URL parts keep their serialization: {rendered}"
648+ ) ;
649+ assert ! (
650+ rendered. contains( r#""type":"image""# )
651+ && rendered. contains( r#""media_type":"image/png""# )
652+ && rendered. contains( r#""bytes":4"# )
653+ && rendered. contains( r#""media_type":"application/pdf""# )
654+ && rendered. contains( r#""bytes":2"# ) ,
655+ "binary parts render as media type plus length: {rendered}"
656+ ) ;
657+ assert ! (
658+ !rendered. contains( "AQIDBA==" ) && !rendered. contains( "CQk=" ) ,
659+ "base64 payloads must not leak: {rendered}"
660+ ) ;
661+ }
662+
594663 // ========================================================================
595664 // HookedAgent::run_stream integration
596665 // ========================================================================
0 commit comments