Skip to content

Commit b3282ea

Browse files
authored
Merge pull request #187 from rostilos/1.5.7-rc
feat: Enhance summarization and asking features with improved fallbac…
2 parents b85b5b1 + 7ea5a2c commit b3282ea

2 files changed

Lines changed: 429 additions & 55 deletions

File tree

python-ecosystem/inference-orchestrator/src/service/command/command_service.py

Lines changed: 239 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nIf 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\nIf 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\nIf 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

Comments
 (0)