diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ag2_multiagent_document_processing/README.md b/examples/ag2_multiagent_document_processing/README.md new file mode 100644 index 0000000000..c0cd737aeb --- /dev/null +++ b/examples/ag2_multiagent_document_processing/README.md @@ -0,0 +1,49 @@ +# AG2 Multi-Agent Document Processing with Unstructured + +This example demonstrates how to use [AG2](https://ag2.ai/) (formerly AutoGen) +multi-agent conversations with [Unstructured](https://unstructured.io/) for +intelligent document processing and analysis. + +## Overview + +Two AG2 agents collaborate to process and analyze documents: + +- **Document Agent** -- Uses Unstructured to partition documents and extract + structured elements (text, tables, titles, narrative) +- **Analyst Agent** -- Analyzes the extracted content, answers questions, + and produces summaries with source references + +## Prerequisites + +- Python >= 3.11 +- OpenAI API key +- System dependencies: `libmagic-dev`, `poppler-utils`, `tesseract-ocr` (for PDF/image support) + +## Quick Start + +```bash +# Install dependencies +pip install "unstructured[all-docs]" "ag2[openai]>=0.11.4,<1.0" + +# Set API key +export OPENAI_API_KEY="your-api-key" + +# Run the example (uses sample docs from example-docs/) +python run.py + +# Or specify your own document +python run.py --file /path/to/your/document.pdf +``` + +## How It Works + +1. **Unstructured** partitions a document into structured elements + (Title, NarrativeText, Table, ListItem, etc.) +2. **AG2 Document Agent** wraps Unstructured as a registered tool, callable by agents +3. **AG2 Analyst Agent** receives extracted elements and produces analysis +4. Agents collaborate via AG2 GroupChat with automatic tool execution + +## Tech Stack + +- [AG2](https://ag2.ai/) -- Multi-agent conversation framework (500K+ monthly PyPI downloads) +- [Unstructured](https://unstructured.io/) -- Document ETL for LLMs (25+ file types) diff --git a/examples/ag2_multiagent_document_processing/__init__.py b/examples/ag2_multiagent_document_processing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ag2_multiagent_document_processing/run.py b/examples/ag2_multiagent_document_processing/run.py new file mode 100644 index 0000000000..816db3362e --- /dev/null +++ b/examples/ag2_multiagent_document_processing/run.py @@ -0,0 +1,366 @@ +"""AG2 Multi-Agent Document Processing with Unstructured. + +This example demonstrates how AG2 (formerly AutoGen) multi-agent framework +can leverage Unstructured for intelligent document processing and analysis. + +Two AG2 agents collaborate: +- **Document Agent**: Partitions documents using Unstructured, extracting + structured elements (titles, narrative text, tables, list items, etc.) +- **Analyst Agent**: Analyzes extracted content, answers questions, and + produces summaries with element-type awareness. + +Requirements: + pip install "unstructured[all-docs]" "ag2[openai]>=0.11.4,<1.0" + +Usage: + export OPENAI_API_KEY=sk-... + python examples/ag2_multiagent_document_processing/run.py + python examples/ag2_multiagent_document_processing/run.py --file path/to/doc.pdf + +AG2: https://ag2.ai/ | 500K+ monthly PyPI downloads +Unstructured: https://unstructured.io/ | 25+ document types +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Optional + +from autogen import ( + AssistantAgent, + GroupChat, + GroupChatManager, + LLMConfig, + UserProxyAgent, +) + +from unstructured.partition.auto import partition +from unstructured.staging.base import elements_to_dicts + +# --------------------------------------------------------------------------- +# 1. Document processing functions using Unstructured +# --------------------------------------------------------------------------- + + +def partition_document(file_path: str, strategy: str = "hi_res") -> list[dict]: + """Partition a document into structured elements using Unstructured. + + Uses ``hi_res`` strategy by default so that confidence scores + (``detection_class_prob``) are populated for PDF/image files. + Falls back to ``auto`` if hi_res fails (e.g. missing system deps). + + Args: + file_path: Path to the document file. + strategy: Partitioning strategy (``hi_res``, ``fast``, ``auto``). + + Returns: + List of element dicts with type, text, and metadata. + """ + try: + elements = partition(filename=file_path, strategy=strategy) + except Exception: + # Fall back to auto if hi_res is unavailable + elements = partition(filename=file_path, strategy="auto") + return elements_to_dicts(elements) + + +def format_elements_summary(elements: list[dict]) -> str: + """Format extracted elements into a readable summary. + + Args: + elements: List of element dicts from Unstructured. + + Returns: + Formatted string with element types and content. + """ + type_counts: dict[str, int] = {} + for el in elements: + el_type = el.get("type", "Unknown") + type_counts[el_type] = type_counts.get(el_type, 0) + 1 + + summary_parts = [ + f"Document contains {len(elements)} elements:", + f"Element types: {json.dumps(type_counts, indent=2)}", + "", + "--- Extracted Content ---", + "", + ] + + for i, el in enumerate(elements, 1): + el_type = el.get("type", "Unknown") + text = el.get("text", "").strip() + if text: + metadata = el.get("metadata", {}) + conf = metadata.get("detection_class_prob") + origin = metadata.get("detection_origin", "") + conf_str = f" [conf={conf:.2f}]" if conf is not None else "" + origin_str = f" [origin={origin}]" if origin else "" + summary_parts.append(f"[{i}] ({el_type}){conf_str}{origin_str} {text[:500]}") + if len(text) > 500: + summary_parts.append(f" ... (truncated, {len(text)} chars total)") + summary_parts.append("") + + return "\n".join(summary_parts) + + +def filter_elements_by_confidence( + elements: list[dict], + min_confidence: float = 0.5, +) -> dict: + """Filter elements by detection confidence score. + + Elements without a confidence score are kept by default (they come from + non-Hi-Res strategies where confidence is not computed). + + Args: + elements: List of element dicts from Unstructured. + min_confidence: Minimum confidence threshold (0.0-1.0). + + Returns: + Dict with 'kept', 'filtered_out' lists and summary stats. + """ + kept = [] + filtered_out = [] + no_score_count = 0 + + for el in elements: + conf = el.get("metadata", {}).get("detection_class_prob") + if conf is None: + kept.append(el) + no_score_count += 1 + elif conf >= min_confidence: + kept.append(el) + else: + filtered_out.append(el) + + return { + "kept": kept, + "filtered_out": filtered_out, + "stats": { + "total": len(elements), + "kept": len(kept), + "filtered_out": len(filtered_out), + "no_score": no_score_count, + "min_confidence": min_confidence, + }, + } + + +# --------------------------------------------------------------------------- +# 2. Find default sample document +# --------------------------------------------------------------------------- + + +def find_sample_document() -> str: + """Find a sample document from the example-docs directory. + + Returns: + Path to a sample document. + + Raises: + FileNotFoundError: If no suitable sample document is found. + """ + repo_root = Path(__file__).resolve().parent.parent.parent + example_docs = repo_root / "example-docs" + + if example_docs.exists(): + # Prefer PDFs (Hi-Res strategy yields confidence scores), then HTML + for pattern in [ + "pdf/layout-parser-paper.pdf", + "pdf/*.pdf", + "example-10k.html", + "*.pdf", + "*.html", + ]: + matches = list(example_docs.glob(pattern)) + if matches: + return str(matches[0]) + + raise FileNotFoundError( + "No sample document found. Use --file to specify a document path, " + "or run from the repository root where example-docs/ exists." + ) + + +# --------------------------------------------------------------------------- +# 3. AG2 agents with Unstructured tools +# --------------------------------------------------------------------------- + + +def main(file_path: Optional[str] = None) -> None: + """Run the AG2 multi-agent document processing pipeline. + + Args: + file_path: Optional path to a document. If None, uses a sample document. + """ + if file_path is None: + file_path = find_sample_document() + + if not os.path.exists(file_path): + print(f"Error: File not found: {file_path}") + sys.exit(1) + + print(f"Processing document: {file_path}") + print(f"File size: {os.path.getsize(file_path):,} bytes") + print() + + llm_config = LLMConfig( + { + "model": "gpt-4o-mini", + "api_key": os.getenv("OPENAI_API_KEY"), + "api_type": "openai", + } + ) + + document_agent = AssistantAgent( + name="document_agent", + system_message=( + "You are a document processing agent. When asked to process a document, " + "use the process_document tool to extract structured content using " + "Unstructured. Present the results clearly, noting the different element " + "types found (Title, NarrativeText, Table, ListItem, etc.)." + ), + llm_config=llm_config, + ) + + analyst = AssistantAgent( + name="analyst", + system_message=( + "You are a document analyst. Based on the extracted document content " + "provided by the document_agent, produce a comprehensive analysis " + "including: (1) document structure overview, (2) key topics and themes, " + "(3) important facts or data points, (4) a concise executive summary. " + "Reference specific element types and content. If confidence scores " + "are available, note which elements have low confidence. " + "End with TERMINATE when done." + ), + llm_config=llm_config, + ) + + user_proxy = UserProxyAgent( + name="user_proxy", + human_input_mode="NEVER", + max_consecutive_auto_reply=10, + code_execution_config=False, + is_termination_msg=lambda x: bool( + x.get("content", "") and "TERMINATE" in x.get("content", "") + ), + ) + + @user_proxy.register_for_execution() + @document_agent.register_for_llm( + description=( + "Process a document file using Unstructured to extract structured " + "content elements. Supports PDF, HTML, Word, PowerPoint, email, " + "images (with OCR), and 25+ other file types. Returns element types " + "(Title, NarrativeText, Table, ListItem, etc.) with their text content." + ) + ) + def process_document(document_path: str) -> str: + """Process a document with Unstructured and return structured elements.""" + print(f"\n>>> [TOOL CALL] process_document('{document_path}')") + print(">>> Partitioning document with Unstructured...") + elements = partition_document(document_path) + summary = format_elements_summary(elements) + print(f">>> Extracted {len(elements)} elements") + print(f">>> Summary length: {len(summary)} chars") + print(">>> [TOOL DONE]\n") + return summary + + @user_proxy.register_for_execution() + @document_agent.register_for_llm( + description=( + "List the types of elements found in a document after processing. " + "Useful for understanding document structure before deep analysis." + ) + ) + def get_element_types(document_path: str) -> str: + """Get a summary of element types in a document.""" + print(f"\n>>> [TOOL CALL] get_element_types('{document_path}')") + print(">>> Scanning document structure...") + elements = partition_document(document_path) + type_counts: dict[str, int] = {} + for el in elements: + el_type = el.get("type", "Unknown") + type_counts[el_type] = type_counts.get(el_type, 0) + 1 + result = json.dumps( + { + "total_elements": len(elements), + "element_types": type_counts, + }, + indent=2, + ) + print(f">>> Found {len(elements)} elements across {len(type_counts)} types") + print(">>> [TOOL DONE]\n") + return result + + @user_proxy.register_for_execution() + @document_agent.register_for_llm( + description=( + "Filter document elements by confidence score. Removes low-confidence " + "extractions (e.g. rotated tables, blurry OCR). Confidence scores are " + "available when using Hi-Res strategy. Elements without a score are kept. " + "Returns filtered elements summary and stats." + ) + ) + def filter_low_confidence( + document_path: str, + min_confidence: float = 0.5, + ) -> str: + """Filter out low-confidence elements from a document.""" + print(f"\n>>> [TOOL CALL] filter_low_confidence('{document_path}', {min_confidence})") + elements = partition_document(document_path) + result = filter_elements_by_confidence(elements, min_confidence) + stats = result["stats"] + print(f">>> Kept {stats['kept']}/{stats['total']} elements (threshold={min_confidence})") + print(f">>> Filtered out: {stats['filtered_out']}, No score: {stats['no_score']}") + print(">>> [TOOL DONE]\n") + summary = format_elements_summary(result["kept"]) + return f"Confidence filter stats: {json.dumps(stats, indent=2)}\n\n{summary}" + + group_chat = GroupChat( + agents=[user_proxy, document_agent, analyst], + messages=[], + max_round=12, + ) + + manager = GroupChatManager( + groupchat=group_chat, + llm_config=llm_config, + ) + + user_proxy.run( + manager, + message=( + f"Process the document at '{file_path}' using the process_document tool, " + f"then have the analyst provide a comprehensive analysis of its content." + ), + ).process() + + +# --------------------------------------------------------------------------- +# 4. CLI entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="AG2 Multi-Agent Document Processing with Unstructured" + ) + parser.add_argument( + "--file", + type=str, + default=None, + help="Path to document file. If not provided, uses a sample from example-docs/.", + ) + args = parser.parse_args() + + print("=" * 60) + print("AG2 + Unstructured: Multi-Agent Document Processing") + print("=" * 60) + print() + + main(file_path=args.file) diff --git a/examples/ag2_multiagent_document_processing/test_e2e.py b/examples/ag2_multiagent_document_processing/test_e2e.py new file mode 100644 index 0000000000..4c9c99153d --- /dev/null +++ b/examples/ag2_multiagent_document_processing/test_e2e.py @@ -0,0 +1,395 @@ +"""End-to-end tests for AG2 + Unstructured document processing example. + +Run these tests to verify the integration works correctly before submitting a PR. + +Usage: + # Run all e2e tests (no API key needed for most tests): + pytest examples/ag2_multiagent_document_processing/test_e2e.py -v + + # Run only offline tests (no API key needed): + pytest examples/ag2_multiagent_document_processing/test_e2e.py -v -k "not live_llm" + + # Run full pipeline test with live LLM (requires OPENAI_API_KEY): + pytest examples/ag2_multiagent_document_processing/test_e2e.py -v -k "live_llm" +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT)) +EXAMPLE_DOCS = REPO_ROOT / "example-docs" + +from examples.ag2_multiagent_document_processing.run import ( # noqa: E402 + filter_elements_by_confidence, + find_sample_document, + format_elements_summary, + partition_document, +) + +# --------------------------------------------------------------------------- +# Test 1: Unstructured can partition documents +# --------------------------------------------------------------------------- + + +class TestUnstructuredPartitioning: + """Verify that Unstructured correctly partitions different document types.""" + + def test_partition_html(self) -> None: + """Partition a sample HTML file and verify elements are extracted.""" + html_files = list(EXAMPLE_DOCS.glob("*.html")) + if not html_files: + pytest.skip("No HTML files in example-docs/") + + elements = partition_document(str(html_files[0])) + + assert isinstance(elements, list), "partition_document should return a list" + assert len(elements) > 0, "HTML should produce at least one element" + + first = elements[0] + assert "type" in first, "Element must have a 'type' field" + + def test_partition_nonexistent_file(self) -> None: + """Verify graceful handling of missing files.""" + with pytest.raises(Exception): + partition_document("/nonexistent/file.pdf") + + def test_element_types_present(self) -> None: + """Verify common element types are detected in a rich document.""" + html_10k = EXAMPLE_DOCS / "example-10k-1p.html" + if not html_10k.exists(): + pytest.skip("example-10k-1p.html not found") + + elements = partition_document(str(html_10k)) + types = {el.get("type") for el in elements} + + assert "Title" in types or "NarrativeText" in types, ( + f"Expected Title or NarrativeText in rich document, got: {types}" + ) + + +# --------------------------------------------------------------------------- +# Test 2: Element formatting works correctly +# --------------------------------------------------------------------------- + + +class TestFormatting: + """Verify that extracted elements are formatted correctly for AG2 agents.""" + + def test_format_elements_summary_basic(self) -> None: + """Test formatting with sample element dicts.""" + elements = [ + {"type": "Title", "text": "Introduction"}, + {"type": "NarrativeText", "text": "This is the main content of the document."}, + {"type": "ListItem", "text": "First item in the list"}, + {"type": "ListItem", "text": "Second item in the list"}, + ] + + summary = format_elements_summary(elements) + + assert "4 elements" in summary, "Should report total element count" + assert "Title" in summary, "Should include Title element type" + assert "NarrativeText" in summary, "Should include NarrativeText" + assert "ListItem" in summary, "Should include ListItem" + assert "Introduction" in summary, "Should include actual text content" + + def test_format_elements_summary_empty(self) -> None: + """Test formatting with empty element list.""" + summary = format_elements_summary([]) + assert "0 elements" in summary + + def test_format_elements_truncation(self) -> None: + """Test that long text is truncated in formatting.""" + elements = [ + {"type": "NarrativeText", "text": "x" * 1000}, + ] + + summary = format_elements_summary(elements) + assert "truncated" in summary, "Long text should be marked as truncated" + + def test_format_includes_confidence_scores(self) -> None: + """Test that confidence scores are shown when present.""" + elements = [ + { + "type": "Title", + "text": "High confidence title", + "metadata": {"detection_class_prob": 0.95}, + }, + { + "type": "NarrativeText", + "text": "No confidence text", + "metadata": {}, + }, + ] + + summary = format_elements_summary(elements) + assert "[conf=0.95]" in summary, "Should show confidence for scored elements" + # The NarrativeText line should NOT have a conf= tag + for line in summary.splitlines(): + if "No confidence text" in line: + assert "[conf=" not in line, "No score element should not show conf=" + + +# --------------------------------------------------------------------------- +# Test 3: Confidence score filtering +# --------------------------------------------------------------------------- + + +class TestConfidenceFiltering: + """Verify confidence-based element filtering (addresses issue #4320).""" + + def test_filter_keeps_high_confidence(self) -> None: + """Elements above threshold are kept.""" + elements = [ + {"type": "Title", "text": "Good", "metadata": {"detection_class_prob": 0.9}}, + {"type": "NarrativeText", "text": "Bad", "metadata": {"detection_class_prob": 0.2}}, + ] + result = filter_elements_by_confidence(elements, min_confidence=0.5) + assert len(result["kept"]) == 1 + assert result["kept"][0]["text"] == "Good" + assert len(result["filtered_out"]) == 1 + + def test_filter_keeps_elements_without_score(self) -> None: + """Elements without confidence scores are kept by default.""" + elements = [ + {"type": "Title", "text": "No score", "metadata": {}}, + {"type": "NarrativeText", "text": "Also no score"}, + ] + result = filter_elements_by_confidence(elements, min_confidence=0.5) + assert len(result["kept"]) == 2 + assert result["stats"]["no_score"] == 2 + + def test_filter_stats_are_correct(self) -> None: + """Verify filter stats report correct counts.""" + elements = [ + {"type": "Title", "text": "A", "metadata": {"detection_class_prob": 0.9}}, + {"type": "Title", "text": "B", "metadata": {"detection_class_prob": 0.3}}, + {"type": "Title", "text": "C", "metadata": {}}, + ] + result = filter_elements_by_confidence(elements, min_confidence=0.5) + stats = result["stats"] + assert stats["total"] == 3 + assert stats["kept"] == 2 + assert stats["filtered_out"] == 1 + assert stats["no_score"] == 1 + assert stats["min_confidence"] == 0.5 + + def test_filter_with_zero_threshold(self) -> None: + """Zero threshold keeps everything with a score.""" + elements = [ + {"type": "Title", "text": "A", "metadata": {"detection_class_prob": 0.01}}, + {"type": "Title", "text": "B", "metadata": {"detection_class_prob": 0.0}}, + ] + result = filter_elements_by_confidence(elements, min_confidence=0.0) + assert len(result["kept"]) == 2 + assert len(result["filtered_out"]) == 0 + + +# --------------------------------------------------------------------------- +# Test 4: Sample document finder works +# --------------------------------------------------------------------------- + + +class TestSampleDocumentFinder: + """Verify the sample document finder locates files correctly.""" + + def test_find_sample_document(self) -> None: + """Find a sample document from example-docs/.""" + if not EXAMPLE_DOCS.exists(): + pytest.skip("example-docs/ not found -- run from repo root") + + doc_path = find_sample_document() + assert os.path.exists(doc_path), f"Sample document should exist: {doc_path}" + assert os.path.getsize(doc_path) > 0, "Sample document should not be empty" + + +# --------------------------------------------------------------------------- +# Test 5: AG2 agent setup works (no LLM call) +# --------------------------------------------------------------------------- + + +class TestAG2AgentSetup: + """Verify AG2 agents and tools are configured correctly (no API calls).""" + + def test_ag2_imports(self) -> None: + """Verify AG2 imports work correctly.""" + from autogen import ( + AssistantAgent, + GroupChat, + GroupChatManager, + LLMConfig, + UserProxyAgent, + ) + + assert AssistantAgent is not None + assert UserProxyAgent is not None + assert GroupChat is not None + assert GroupChatManager is not None + assert LLMConfig is not None + + def test_llm_config_creation(self) -> None: + """Verify LLMConfig accepts the positional dict pattern.""" + from autogen import LLMConfig + + config = LLMConfig( + { + "model": "gpt-4o-mini", + "api_key": "test-key-not-real", + "api_type": "openai", + } + ) + assert config is not None + + def test_agent_creation(self) -> None: + """Verify agents can be created with the correct pattern.""" + from autogen import AssistantAgent, LLMConfig, UserProxyAgent + + llm_config = LLMConfig( + { + "model": "gpt-4o-mini", + "api_key": "test-key-not-real", + "api_type": "openai", + } + ) + + agent = AssistantAgent( + name="test_agent", + system_message="Test agent", + llm_config=llm_config, + ) + assert agent.name == "test_agent" + + proxy = UserProxyAgent( + name="test_proxy", + human_input_mode="NEVER", + max_consecutive_auto_reply=10, + code_execution_config=False, + ) + assert proxy.name == "test_proxy" + + def test_tool_registration(self) -> None: + """Verify tool registration via decorator pattern works.""" + from autogen import AssistantAgent, LLMConfig, UserProxyAgent + + llm_config = LLMConfig( + { + "model": "gpt-4o-mini", + "api_key": "test-key-not-real", + "api_type": "openai", + } + ) + + assistant = AssistantAgent( + name="assistant", + system_message="Test", + llm_config=llm_config, + ) + proxy = UserProxyAgent( + name="proxy", + human_input_mode="NEVER", + code_execution_config=False, + ) + + @proxy.register_for_execution() + @assistant.register_for_llm(description="Test tool") + def test_tool(query: str) -> str: + return f"Result for: {query}" + + assert test_tool("hello") == "Result for: hello" + + +# --------------------------------------------------------------------------- +# Test 6: Full pipeline with LIVE LLM (requires API key) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not os.getenv("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set -- skipping live LLM test", +) +class TestLiveLLMPipeline: + """Full end-to-end test with actual LLM calls. Requires OPENAI_API_KEY.""" + + def test_live_llm_full_pipeline(self) -> None: + """Run the complete AG2 + Unstructured pipeline with a real LLM.""" + from autogen import ( + AssistantAgent, + GroupChat, + GroupChatManager, + LLMConfig, + UserProxyAgent, + ) + + test_doc = EXAMPLE_DOCS / "example-10k-1p.html" + if not test_doc.exists(): + html_files = list(EXAMPLE_DOCS.glob("*.html")) + if not html_files: + pytest.skip("No HTML test documents available") + test_doc = html_files[0] + + llm_config = LLMConfig( + { + "model": "gpt-4o-mini", + "api_key": os.getenv("OPENAI_API_KEY"), + "api_type": "openai", + } + ) + + document_agent = AssistantAgent( + name="document_agent", + system_message=( + "You are a document processing agent. Use the process_document " + "tool to extract content from the specified document." + ), + llm_config=llm_config, + ) + + analyst = AssistantAgent( + name="analyst", + system_message=("Summarize the document content in 2-3 sentences. End with TERMINATE."), + llm_config=llm_config, + ) + + user_proxy = UserProxyAgent( + name="user_proxy", + human_input_mode="NEVER", + max_consecutive_auto_reply=5, + code_execution_config=False, + is_termination_msg=lambda x: "TERMINATE" in x.get("content", ""), + ) + + @user_proxy.register_for_execution() + @document_agent.register_for_llm( + description="Process a document and extract structured content." + ) + def process_document(document_path: str) -> str: + elements = partition_document(document_path) + return format_elements_summary(elements[:20]) + + group_chat = GroupChat( + agents=[user_proxy, document_agent, analyst], + messages=[], + max_round=8, + ) + + manager = GroupChatManager(groupchat=group_chat, llm_config=llm_config) + + user_proxy.run( + manager, + message=f"Process the document at '{test_doc}' and summarize it.", + ).process() + + all_messages = group_chat.messages + assert len(all_messages) > 2, ( + f"Expected multi-turn conversation, got {len(all_messages)} messages" + ) + + last_messages = [m.get("content", "") for m in all_messages[-3:]] + assert any("TERMINATE" in msg for msg in last_messages), ( + "Analyst should have terminated the conversation" + )