@@ -283,7 +283,7 @@ pub(crate) fn build_error_content(
283283 inline_full_args,
284284 ) ;
285285 }
286- payload
286+ compact_model_tool_payload ( payload)
287287 } else {
288288 let mut payload = serde_json:: json!( {
289289 "error" : error_text,
@@ -293,7 +293,7 @@ pub(crate) fn build_error_content(
293293 "next_action" : next_action,
294294 } ) ;
295295 push_error_truncation_flag ( & mut payload, error_truncated) ;
296- payload
296+ compact_model_tool_payload ( payload)
297297 }
298298}
299299
@@ -492,9 +492,9 @@ pub(crate) async fn handle_tool_execution_result<'a>(
492492 Ok ( None )
493493}
494494
495- pub ( super ) fn maybe_inline_spooled ( _tool_name : & str , output : & serde_json:: Value ) -> String {
495+ pub ( crate ) fn compact_model_tool_payload ( output : serde_json:: Value ) -> serde_json :: Value {
496496 let Some ( obj) = output. as_object ( ) else {
497- return serialize_output ( output) ;
497+ return output;
498498 } ;
499499
500500 let next_continue_args = obj
@@ -510,6 +510,10 @@ pub(super) fn maybe_inline_spooled(_tool_name: &str, output: &serde_json::Value)
510510 . map ( |next_continue| next_continue. session_id . as_str ( ) )
511511 } ) ;
512512 let has_spool_path = obj. contains_key ( "spool_path" ) ;
513+ let loop_detected = obj
514+ . get ( "loop_detected" )
515+ . and_then ( serde_json:: Value :: as_bool)
516+ . unwrap_or ( false ) ;
513517 let is_exec_like = obj. contains_key ( "command" )
514518 || obj. contains_key ( "working_directory" )
515519 || session_id. is_some ( )
@@ -538,26 +542,35 @@ pub(super) fn maybe_inline_spooled(_tool_name: &str, output: &serde_json::Value)
538542 "spool_hint" | "spooled_bytes" | "spooled_to_file" => has_spool_path,
539543 "id" => session_id. is_some_and ( |sid| value == sid) ,
540544 "working_directory" => is_exec_like,
545+ "next_action" => true ,
541546 "has_more" | "preferred_next_action" => {
542547 next_continue_args. is_some ( ) || next_read_args. is_some ( ) || is_false_bool ( value)
543548 }
544549 "session_id" | "command" => is_exec_like,
545- "spool_path" => next_read_args
546- . as_ref ( )
547- . is_some_and ( |next_read| value == next_read. path . as_str ( ) ) ,
550+ "spool_path" => {
551+ !loop_detected
552+ && next_read_args
553+ . as_ref ( )
554+ . is_some_and ( |next_read| value == next_read. path . as_str ( ) )
555+ }
548556 "next_offset" => next_read_args
549557 . as_ref ( )
550558 . is_some_and ( |next_read| value_matches_usize ( value, next_read. offset ) ) ,
551559 "chunk_limit" => next_read_args
552560 . as_ref ( )
553561 . is_some_and ( |next_read| value_matches_usize ( value, next_read. limit ) ) ,
554562 "stderr_preview" => has_stderr,
555- "truncated"
556- | "auto_recovered"
563+ "loop_detected_note"
564+ | "spool_ref_only"
565+ | "result_ref_only"
557566 | "reused_spooled_output"
558567 | "reused_recent_result"
559- | "loop_detected"
560- | "query_truncated" => is_false_bool ( value) ,
568+ | "repeat_count"
569+ | "tool" => loop_detected,
570+ "limit" => loop_detected && obj. get ( "tool" ) . is_some ( ) ,
571+ "truncated" | "auto_recovered" | "loop_detected" | "query_truncated" => {
572+ is_false_bool ( value)
573+ }
561574 "stdout" => output_value. is_some_and ( |output| output == value) ,
562575 "process_id" => is_exec_like || process_session_id. is_some_and ( |sid| value == sid) ,
563576 "is_exited" => {
@@ -581,7 +594,11 @@ pub(super) fn maybe_inline_spooled(_tool_name: &str, output: &serde_json::Value)
581594 sanitized. insert ( key. clone ( ) , cloned_value) ;
582595 }
583596
584- serialize_output ( & serde_json:: Value :: Object ( sanitized) )
597+ serde_json:: Value :: Object ( sanitized)
598+ }
599+
600+ pub ( super ) fn maybe_inline_spooled ( _tool_name : & str , output : & serde_json:: Value ) -> String {
601+ serialize_output ( & compact_model_tool_payload ( output. clone ( ) ) )
585602}
586603
587604fn compact_next_continue_args ( value : & serde_json:: Value ) -> serde_json:: Value {
@@ -1118,6 +1135,31 @@ mod tests {
11181135 . and_then( |v| v. as_str( ) )
11191136 . is_some( )
11201137 ) ;
1138+ assert ! ( payload. get( "next_action" ) . is_none( ) ) ;
1139+ }
1140+
1141+ #[ test]
1142+ fn build_error_content_keeps_structured_fallback_fields_only ( ) {
1143+ let payload = build_error_content (
1144+ "boom" . to_string ( ) ,
1145+ Some ( tool_names:: UNIFIED_SEARCH . to_string ( ) ) ,
1146+ Some ( serde_json:: json!( { "action" : "list" , "path" : "." } ) ) ,
1147+ "execution" ,
1148+ ) ;
1149+
1150+ assert_eq ! (
1151+ payload. get( "fallback_tool" ) ,
1152+ Some ( & serde_json:: json!( tool_names:: UNIFIED_SEARCH ) )
1153+ ) ;
1154+ assert_eq ! (
1155+ payload. get( "fallback_tool_args" ) ,
1156+ Some ( & serde_json:: json!( { "action" : "list" , "path" : "." } ) )
1157+ ) ;
1158+ assert_eq ! (
1159+ payload. get( "is_recoverable" ) . and_then( |v| v. as_bool( ) ) ,
1160+ Some ( true )
1161+ ) ;
1162+ assert ! ( payload. get( "next_action" ) . is_none( ) ) ;
11211163 }
11221164
11231165 #[ test]
@@ -1257,6 +1299,52 @@ mod tests {
12571299 ) ;
12581300 }
12591301
1302+ #[ test]
1303+ fn maybe_inline_spooled_keeps_loop_recovery_fields_and_drops_notes ( ) {
1304+ let serialized = maybe_inline_spooled (
1305+ tool_names:: READ_FILE ,
1306+ & serde_json:: json!( {
1307+ "loop_detected" : true ,
1308+ "spool_path" : ".vtcode/context/tool_outputs/unified_exec_loop.txt" ,
1309+ "next_read_args" : {
1310+ "path" : ".vtcode/context/tool_outputs/unified_exec_loop.txt" ,
1311+ "offset" : 81 ,
1312+ "limit" : 40
1313+ } ,
1314+ "reused_spooled_output" : true ,
1315+ "spool_ref_only" : true ,
1316+ "loop_detected_note" : "Read the spool file instead of re-running this call." ,
1317+ "repeat_count" : 4 ,
1318+ "limit" : 3 ,
1319+ "tool" : "read_file"
1320+ } ) ,
1321+ ) ;
1322+
1323+ let parsed: serde_json:: Value =
1324+ serde_json:: from_str ( & serialized) . expect ( "serialized JSON payload" ) ;
1325+ assert_eq ! ( parsed. get( "loop_detected" ) , Some ( & serde_json:: json!( true ) ) ) ;
1326+ assert_eq ! (
1327+ parsed. get( "spool_path" ) ,
1328+ Some ( & serde_json:: json!(
1329+ ".vtcode/context/tool_outputs/unified_exec_loop.txt"
1330+ ) )
1331+ ) ;
1332+ assert_eq ! (
1333+ parsed. get( "next_read_args" ) ,
1334+ Some ( & serde_json:: json!( {
1335+ "path" : ".vtcode/context/tool_outputs/unified_exec_loop.txt" ,
1336+ "offset" : 81 ,
1337+ "limit" : 40
1338+ } ) )
1339+ ) ;
1340+ assert ! ( parsed. get( "reused_spooled_output" ) . is_none( ) ) ;
1341+ assert ! ( parsed. get( "spool_ref_only" ) . is_none( ) ) ;
1342+ assert ! ( parsed. get( "loop_detected_note" ) . is_none( ) ) ;
1343+ assert ! ( parsed. get( "repeat_count" ) . is_none( ) ) ;
1344+ assert ! ( parsed. get( "limit" ) . is_none( ) ) ;
1345+ assert ! ( parsed. get( "tool" ) . is_none( ) ) ;
1346+ }
1347+
12601348 #[ test]
12611349 fn maybe_inline_spooled_drops_terminal_exec_metadata_without_continuation ( ) {
12621350 let serialized = maybe_inline_spooled (
0 commit comments