@@ -717,56 +717,142 @@ async def _execute_summarize(
717717 # Intermediate text output
718718 final_result = item
719719
720+ else :
721+ extracted = self ._extract_agent_item_text (item )
722+ if extracted is not None :
723+ final_result = extracted
724+
720725 self ._emit_event (event_callback , {
721726 "type" : "progress" ,
722727 "step" : self .MAX_STEPS_SUMMARIZE ,
723728 "max_steps" : self .MAX_STEPS_SUMMARIZE ,
724729 "message" : f"Summarization completed ({ step_count } tool calls)"
725730 })
726731
727- # Process the result
728- if isinstance (final_result , SummarizeOutput ):
729- logger .info ("Successfully received structured summarize output" )
730- return {
731- "summary" : final_result .summary ,
732- "diagram" : final_result .diagram ,
733- "diagramType" : final_result .diagramType
734- }
735- elif isinstance (final_result , str ) and final_result :
736- # Fallback: parse string result
737- logger .debug (f"Summarize raw result (first 500 chars): { final_result [:500 ] if final_result else 'None' } " )
738- parsed = self ._parse_json_response (final_result )
739- if parsed :
740- logger .info ("Successfully parsed JSON response for summarize" )
741- return {
742- "summary" : parsed .get ("summary" , "" ),
743- "diagram" : parsed .get ("diagram" , "" ),
744- "diagramType" : parsed .get ("diagramType" , "MERMAID" if supports_mermaid else "ASCII" )
745- }
746- else :
747- # Try regex extraction as last resort
748- extracted = self ._extract_summary_field_fallback (final_result )
749- if extracted :
750- logger .warning ("Used regex fallback to extract summary field" )
751- return {
752- "summary" : extracted ,
753- "diagram" : "" ,
754- "diagramType" : "MERMAID" if supports_mermaid else "ASCII"
755- }
756-
757- logger .warning (f"JSON parsing failed for summarize, using raw result" )
758- return {
759- "summary" : final_result or "Failed to generate summary" ,
760- "diagram" : "" ,
761- "diagramType" : "MERMAID" if supports_mermaid else "ASCII"
762- }
763- else :
764- return {"error" : "AI service returned an empty summary" }
732+ result = self ._coerce_summarize_final_result (final_result , supports_mermaid )
733+ if "error" not in result :
734+ return result
735+
736+ logger .warning ("Summarize streaming produced an empty final summary; retrying without output_schema" )
737+ self ._emit_event (event_callback , {
738+ "type" : "status" ,
739+ "state" : "retrying" ,
740+ "message" : "Retrying summary generation"
741+ })
742+
743+ raw_result = await self ._run_agent_with_heartbeat (
744+ agent = agent ,
745+ prompt = prompt ,
746+ event_callback = event_callback ,
747+ max_steps = self .MAX_STEPS_SUMMARIZE
748+ )
749+ result = self ._coerce_summarize_final_result (raw_result , supports_mermaid )
750+ if "error" not in result :
751+ return result
752+
753+ logger .warning ("Summarize agent retry also produced an empty summary; trying direct LLM fallback" )
754+ direct_response = await llm .ainvoke (
755+ prompt
756+ + "\n \n If tool calls are unavailable, summarize from the context already provided. "
757+ "Return a JSON object with non-empty 'summary', 'diagram', and 'diagramType' fields."
758+ )
759+ return self ._coerce_summarize_final_result (direct_response , supports_mermaid )
765760
766761 except Exception as e :
767- logger .error (f"Summarize agent error: { e } " , exc_info = True )
768- sanitized_msg = create_user_friendly_error (e )
769- return {"error" : sanitized_msg }
762+ logger .warning (f"Summarize streaming failed, retrying without output_schema: { e } " , exc_info = True )
763+ self ._emit_event (event_callback , {
764+ "type" : "status" ,
765+ "state" : "retrying" ,
766+ "message" : "Retrying summary generation"
767+ })
768+ try :
769+ raw_result = await self ._run_agent_with_heartbeat (
770+ agent = agent ,
771+ prompt = prompt ,
772+ event_callback = event_callback ,
773+ max_steps = self .MAX_STEPS_SUMMARIZE
774+ )
775+ result = self ._coerce_summarize_final_result (raw_result , supports_mermaid )
776+ if "error" not in result :
777+ return result
778+
779+ direct_response = await llm .ainvoke (
780+ prompt
781+ + "\n \n If tool calls are unavailable, summarize from the context already provided. "
782+ "Return a JSON object with non-empty 'summary', 'diagram', and 'diagramType' fields."
783+ )
784+ return self ._coerce_summarize_final_result (direct_response , supports_mermaid )
785+ except Exception as retry_error :
786+ logger .error (f"Summarize agent error: { retry_error } " , exc_info = True )
787+ sanitized_msg = create_user_friendly_error (retry_error )
788+ return {"error" : sanitized_msg }
789+
790+ def _coerce_summarize_final_result (
791+ self ,
792+ final_result : Any ,
793+ supports_mermaid : bool
794+ ) -> Dict [str , Any ]:
795+ """Convert structured, dict, message, or text agent output into a summary dict."""
796+ diagram_type = "MERMAID" if supports_mermaid else "ASCII"
797+
798+ if isinstance (final_result , SummarizeOutput ):
799+ logger .info ("Successfully received structured summarize output" )
800+ return self ._summary_or_empty_error (
801+ summary = final_result .summary ,
802+ diagram = final_result .diagram ,
803+ diagram_type = final_result .diagramType or diagram_type ,
804+ )
805+
806+ if isinstance (final_result , dict ) and "summary" in final_result :
807+ return self ._summary_or_empty_error (
808+ summary = final_result .get ("summary" ),
809+ diagram = final_result .get ("diagram" ),
810+ diagram_type = final_result .get ("diagramType" ) or diagram_type ,
811+ )
812+
813+ text = self ._extract_agent_item_text (final_result )
814+ if not self ._has_usable_text (text ):
815+ return {"error" : "AI service returned an empty summary" }
816+
817+ logger .debug (f"Summarize raw result (first 500 chars): { str (text )[:500 ] if text else 'None' } " )
818+ parsed = self ._parse_json_response (str (text ))
819+ if parsed :
820+ logger .info ("Successfully parsed JSON response for summarize" )
821+ return self ._summary_or_empty_error (
822+ summary = parsed .get ("summary" ),
823+ diagram = parsed .get ("diagram" ),
824+ diagram_type = parsed .get ("diagramType" ) or diagram_type ,
825+ )
826+
827+ extracted = self ._extract_summary_field_fallback (str (text ))
828+ if extracted :
829+ logger .warning ("Used regex fallback to extract summary field" )
830+ return self ._summary_or_empty_error (
831+ summary = extracted ,
832+ diagram = "" ,
833+ diagram_type = diagram_type ,
834+ )
835+
836+ logger .warning ("JSON parsing failed for summarize, using raw result" )
837+ return self ._summary_or_empty_error (
838+ summary = str (text ),
839+ diagram = "" ,
840+ diagram_type = diagram_type ,
841+ )
842+
843+ def _summary_or_empty_error (
844+ self ,
845+ summary : Any ,
846+ diagram : Any ,
847+ diagram_type : Any
848+ ) -> Dict [str , Any ]:
849+ if not self ._has_usable_text (summary ):
850+ return {"error" : "AI service returned an empty summary" }
851+ return {
852+ "summary" : str (summary ),
853+ "diagram" : self ._string_or_empty (diagram ),
854+ "diagramType" : str (diagram_type or "ASCII" ),
855+ }
770856
771857 def _extract_summary_field_fallback (self , text : str ) -> Optional [str ]:
772858 """
@@ -858,32 +944,131 @@ async def _execute_ask(
858944 # Intermediate text output
859945 final_result = item
860946
947+ else :
948+ extracted = self ._extract_agent_item_text (item )
949+ if extracted is not None :
950+ final_result = extracted
951+
861952 self ._emit_event (event_callback , {
862953 "type" : "progress" ,
863954 "step" : self .MAX_STEPS_ASK ,
864955 "max_steps" : self .MAX_STEPS_ASK ,
865956 "message" : f"Completed ({ step_count } tool calls)"
866957 })
867958
868- # Process the result
869- if isinstance (final_result , AskOutput ):
870- logger .info ("Successfully received structured ask output" )
871- return {"answer" : final_result .answer }
872- elif isinstance (final_result , str ) and final_result :
873- # Fallback: parse string result
874- parsed = self ._parse_json_response (final_result )
875- if parsed and "answer" in parsed :
876- return {"answer" : parsed ["answer" ]}
877- else :
878- return {"answer" : final_result }
879- else :
880- return {"error" : "AI service returned an empty answer" }
959+ result = self ._coerce_ask_final_result (final_result )
960+ if "error" not in result :
961+ return result
962+
963+ logger .warning ("Ask streaming produced an empty final answer; retrying without output_schema" )
964+ self ._emit_event (event_callback , {
965+ "type" : "status" ,
966+ "state" : "retrying" ,
967+ "message" : "Retrying answer generation"
968+ })
969+
970+ raw_result = await self ._run_agent_with_heartbeat (
971+ agent = agent ,
972+ prompt = prompt ,
973+ event_callback = event_callback ,
974+ max_steps = self .MAX_STEPS_ASK
975+ )
976+ result = self ._coerce_ask_final_result (raw_result )
977+ if "error" not in result :
978+ return result
979+
980+ logger .warning ("Ask agent retry also produced an empty answer; trying direct LLM fallback" )
981+ direct_response = await llm .ainvoke (
982+ prompt
983+ + "\n \n If tool calls are unavailable, answer from the context already provided. "
984+ "Return a JSON object with a non-empty 'answer' field."
985+ )
986+ return self ._coerce_ask_final_result (direct_response )
881987
882988 except Exception as e :
883989 logger .error (f"Ask agent error: { e } " , exc_info = True )
884990 sanitized_msg = create_user_friendly_error (e )
885991 return {"error" : sanitized_msg }
886992
993+ def _coerce_ask_final_result (self , final_result : Any ) -> Dict [str , Any ]:
994+ """Convert structured, dict, message, or text agent output into an answer dict."""
995+ if isinstance (final_result , AskOutput ):
996+ logger .info ("Successfully received structured ask output" )
997+ return self ._answer_or_empty_error (final_result .answer )
998+
999+ if isinstance (final_result , dict ) and "answer" in final_result :
1000+ return self ._answer_or_empty_error (final_result .get ("answer" ))
1001+
1002+ text = self ._extract_agent_item_text (final_result )
1003+ if not self ._has_usable_text (text ):
1004+ return {"error" : "AI service returned an empty answer" }
1005+
1006+ parsed = self ._parse_json_response (str (text ))
1007+ if parsed and "answer" in parsed :
1008+ return self ._answer_or_empty_error (parsed .get ("answer" ))
1009+
1010+ return {"answer" : str (text )}
1011+
1012+ def _answer_or_empty_error (self , answer : Any ) -> Dict [str , Any ]:
1013+ if not self ._has_usable_text (answer ):
1014+ return {"error" : "AI service returned an empty answer" }
1015+ return {"answer" : str (answer )}
1016+
1017+ def _extract_agent_item_text (self , item : Any ) -> Optional [str ]:
1018+ """Extract final text from common LangChain/mcp_use stream item shapes."""
1019+ if item is None :
1020+ return None
1021+
1022+ if isinstance (item , str ):
1023+ return item
1024+
1025+ if isinstance (item , dict ):
1026+ for key in ("answer" , "output" , "final_output" , "response" , "result" , "content" , "text" ):
1027+ if key in item :
1028+ return self ._extract_agent_item_text (item .get (key ))
1029+
1030+ messages = item .get ("messages" )
1031+ if isinstance (messages , list ) and messages :
1032+ return self ._extract_agent_item_text (messages [- 1 ])
1033+
1034+ return None
1035+
1036+ if hasattr (item , "content" ):
1037+ return self ._coerce_text_content (getattr (item , "content" ))
1038+
1039+ if hasattr (item , "model_dump" ):
1040+ try :
1041+ dumped = item .model_dump ()
1042+ if isinstance (dumped , dict ):
1043+ return self ._extract_agent_item_text (dumped )
1044+ except Exception :
1045+ return None
1046+
1047+ return None
1048+
1049+ def _coerce_text_content (self , content : Any ) -> str :
1050+ """Convert provider content blocks to plain text."""
1051+ if content is None :
1052+ return ""
1053+ if isinstance (content , str ):
1054+ return content
1055+ if isinstance (content , list ):
1056+ parts = []
1057+ for block in content :
1058+ if isinstance (block , str ):
1059+ parts .append (block )
1060+ elif isinstance (block , dict ):
1061+ text = block .get ("text" ) or block .get ("content" )
1062+ if text is not None :
1063+ parts .append (str (text ))
1064+ elif hasattr (block , "text" ):
1065+ parts .append (str (block .text ))
1066+ return "" .join (parts )
1067+ if isinstance (content , dict ):
1068+ text = content .get ("text" ) or content .get ("content" )
1069+ return "" if text is None else str (text )
1070+ return str (content )
1071+
8871072 async def _run_agent_with_heartbeat (
8881073 self ,
8891074 agent : MCPAgent ,
0 commit comments