@@ -382,6 +382,210 @@ def _get_tool_specific_suggestions(
382382 return suggestions
383383
384384
385+ # Tools eligible for dynamic response truncation instead of hard blocking.
386+ # These tools return single objects (not paginated lists) where truncation
387+ # is preferable to returning an error.
388+ INFO_TOOLS = frozenset (
389+ {
390+ "get_chart_info" ,
391+ "get_dataset_info" ,
392+ "get_dashboard_info" ,
393+ "get_instance_info" ,
394+ }
395+ )
396+
397+ # Maximum character length for string fields before truncation
398+ _MAX_STRING_CHARS = 500
399+ # Maximum items to keep in list fields before truncation
400+ _MAX_LIST_ITEMS = 30
401+ # Maximum keys to keep when summarizing large dict fields
402+ _MAX_DICT_KEYS = 20
403+
404+
405+ def _truncate_strings (
406+ data : Dict [str , Any ], notes : List [str ], max_chars : int = _MAX_STRING_CHARS
407+ ) -> bool :
408+ """Truncate string fields exceeding max_chars at the top level only."""
409+ changed = False
410+ for key , value in data .items ():
411+ if isinstance (value , str ) and len (value ) > max_chars :
412+ original_len = len (value )
413+ data [key ] = value [:max_chars ] + f"... [truncated from { original_len } chars]"
414+ notes .append (f"Field '{ key } ' truncated from { original_len } chars" )
415+ changed = True
416+ return changed
417+
418+
419+ def _truncate_strings_recursive (
420+ data : Any ,
421+ notes : List [str ],
422+ max_chars : int = _MAX_STRING_CHARS ,
423+ path : str = "" ,
424+ _depth : int = 0 ,
425+ ) -> bool :
426+ """Recursively truncate strings throughout the entire data tree.
427+
428+ Walks nested dicts and list items to catch strings like
429+ ``charts[0].description`` that top-level truncation misses.
430+ Depth is capped at 10 to avoid runaway recursion.
431+ """
432+ if _depth > 10 :
433+ return False
434+ changed = False
435+ if isinstance (data , dict ):
436+ for key , value in data .items ():
437+ field_path = f"{ path } .{ key } " if path else key
438+ if isinstance (value , str ) and len (value ) > max_chars :
439+ original_len = len (value )
440+ data [key ] = (
441+ value [:max_chars ] + f"... [truncated from { original_len } chars]"
442+ )
443+ notes .append (
444+ f"Field '{ field_path } ' truncated from { original_len } chars"
445+ )
446+ changed = True
447+ elif isinstance (value , (dict , list )):
448+ changed |= _truncate_strings_recursive (
449+ value , notes , max_chars , field_path , _depth + 1
450+ )
451+ elif isinstance (data , list ):
452+ for i , item in enumerate (data ):
453+ if isinstance (item , (dict , list )):
454+ changed |= _truncate_strings_recursive (
455+ item , notes , max_chars , f"{ path } [{ i } ]" , _depth + 1
456+ )
457+ return changed
458+
459+
460+ def _truncate_lists (data : Dict [str , Any ], notes : List [str ], max_items : int ) -> bool :
461+ """Truncate list fields exceeding max_items. Returns True if any truncated.
462+
463+ Does NOT append marker objects into the list to preserve the element type
464+ contract (e.g. ``List[TableColumnInfo]`` stays homogeneous). Truncation
465+ metadata is communicated through the *notes* list and top-level response
466+ fields ``_response_truncated`` / ``_truncation_notes``.
467+ """
468+ changed = False
469+ for key , value in data .items ():
470+ if isinstance (value , list ) and len (value ) > max_items :
471+ original_len = len (value )
472+ data [key ] = value [:max_items ]
473+ notes .append (
474+ f"Field '{ key } ' truncated from { original_len } to { max_items } items"
475+ )
476+ changed = True
477+ return changed
478+
479+
480+ def _summarize_large_dicts (
481+ data : Dict [str , Any ], notes : List [str ], max_keys : int = _MAX_DICT_KEYS
482+ ) -> bool :
483+ """Replace large dict fields with key summaries. Returns True if any changed."""
484+ changed = False
485+ for key , value in data .items ():
486+ if isinstance (value , dict ) and len (value ) > max_keys :
487+ keys_list = list (value .keys ())[:max_keys ]
488+ data [key ] = {
489+ "_truncated" : True ,
490+ "_message" : (
491+ f"Dict with { len (value )} keys truncated. "
492+ f"Keys: { ', ' .join (str (k ) for k in keys_list )} ..."
493+ ),
494+ }
495+ notes .append (f"Field '{ key } ' dict summarized ({ len (value )} keys)" )
496+ changed = True
497+ return changed
498+
499+
500+ def _replace_collections_with_summaries (data : Dict [str , Any ], notes : List [str ]) -> bool :
501+ """Replace all non-empty list/dict fields with empty/minimal values.
502+
503+ Lists are emptied (preserving the list type) rather than replaced with
504+ marker objects to avoid breaking typed list contracts.
505+ """
506+ changed = False
507+ for key , value in list (data .items ()):
508+ if not isinstance (value , (list , dict )) or not value :
509+ continue
510+ count = len (value )
511+ if isinstance (value , list ):
512+ data [key ] = []
513+ notes .append (f"Field '{ key } ' list ({ count } items) cleared to fit limit" )
514+ else :
515+ data [key ] = {}
516+ notes .append (f"Field '{ key } ' dict ({ count } keys) cleared to fit limit" )
517+ changed = True
518+ return changed
519+
520+
521+ def _is_under_limit (data : Dict [str , Any ], token_limit : int ) -> bool :
522+ """Check if the serialized data fits within the token limit."""
523+ from superset .utils import json as utils_json
524+
525+ return estimate_token_count (utils_json .dumps (data )) <= token_limit
526+
527+
528+ def truncate_oversized_response (
529+ response : ToolResponse ,
530+ token_limit : int ,
531+ ) -> tuple [ToolResponse , bool , list [str ]]:
532+ """
533+ Dynamically truncate large fields in a response to fit within the token limit.
534+
535+ Applies five progressive phases of truncation:
536+ 1. Truncate long top-level string fields
537+ 2. Truncate large list fields to _MAX_LIST_ITEMS
538+ 3. Recursively truncate strings in nested structures (list items, nested dicts)
539+ 4. Aggressively reduce lists to 10 items and summarize large dicts
540+ 5. Replace all collections with empty values
541+
542+ Args:
543+ response: The tool response (Pydantic model, dict, or other).
544+ token_limit: Maximum estimated tokens allowed.
545+
546+ Returns:
547+ A tuple of (possibly-truncated response, was_truncated, list of notes).
548+ """
549+ notes : list [str ] = []
550+
551+ # Convert to a mutable dict for manipulation
552+ if hasattr (response , "model_dump" ):
553+ data = response .model_dump ()
554+ elif isinstance (response , dict ):
555+ data = dict (response )
556+ else :
557+ return response , False , notes
558+
559+ was_truncated = False
560+
561+ # Phase 1: Truncate long string fields
562+ was_truncated |= _truncate_strings (data , notes )
563+ if _is_under_limit (data , token_limit ):
564+ return data , was_truncated , notes
565+
566+ # Phase 2: Truncate large list fields
567+ was_truncated |= _truncate_lists (data , notes , _MAX_LIST_ITEMS )
568+ if _is_under_limit (data , token_limit ):
569+ return data , was_truncated , notes
570+
571+ # Phase 3: Recursively truncate strings inside nested structures
572+ # (e.g. charts[i].description, native_filters[i].config, etc.)
573+ was_truncated |= _truncate_strings_recursive (data , notes )
574+ if _is_under_limit (data , token_limit ):
575+ return data , was_truncated , notes
576+
577+ # Phase 4: Aggressively reduce lists and summarize large dicts
578+ was_truncated |= _truncate_lists (data , notes , max_items = 10 )
579+ was_truncated |= _summarize_large_dicts (data , notes )
580+ if _is_under_limit (data , token_limit ):
581+ return data , was_truncated , notes
582+
583+ # Phase 5: Nuclear — replace all collections with empty values
584+ was_truncated |= _replace_collections_with_summaries (data , notes )
585+
586+ return data , was_truncated , notes
587+
588+
385589def format_size_limit_error (
386590 tool_name : str ,
387591 params : Dict [str , Any ] | None ,
0 commit comments