diff --git a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py index 7c21268ea..5b43f505d 100644 --- a/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py +++ b/src/praisonai-agents/praisonaiagents/agent/chat_mixin.py @@ -56,6 +56,43 @@ def _get_display_functions(): class ChatMixin: """Mixin providing chat methods for the Agent class.""" + def _extract_llm_response_content(self, response) -> Optional[str]: + """Extract actual message content from LLM response for better observability. + + Instead of str(response) which shows the entire ChatCompletion object, + this extracts the actual message text that agents produce. + + Args: + response: OpenAI ChatCompletion response object + + Returns: + str: The actual message content, or fallback representation + """ + if not response: + return None + + try: + # Try to extract the actual message content first + if hasattr(response, 'choices') and response.choices: + choice = response.choices[0] + if hasattr(choice, 'message') and hasattr(choice.message, 'content'): + content = choice.message.content + if content: + return content + # Tool-call turn: surface tool_calls summary instead of None + tool_calls = getattr(choice.message, 'tool_calls', None) + if tool_calls: + try: + names = [getattr(tc.function, 'name', '?') for tc in tool_calls] + return f"[tool_calls: {', '.join(names)}]" + except Exception: + pass + except (AttributeError, IndexError, TypeError): + pass + + # Fallback to string representation if extraction fails + return str(response) + def _build_system_prompt(self, tools=None): """Build the system prompt with tool information. @@ -572,7 +609,7 @@ def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, r _trace_emitter.llm_response( self.name, duration_ms=_duration_ms, - response_content=str(final_response) if final_response else None, + response_content=self._extract_llm_response_content(final_response), prompt_tokens=_prompt_tokens, completion_tokens=_completion_tokens, cost_usd=_cost_usd, diff --git a/src/praisonai-agents/praisonaiagents/tools/__init__.py b/src/praisonai-agents/praisonaiagents/tools/__init__.py index 41282468f..014091b1c 100644 --- a/src/praisonai-agents/praisonaiagents/tools/__init__.py +++ b/src/praisonai-agents/praisonaiagents/tools/__init__.py @@ -147,6 +147,10 @@ 'Crawl4AITools': ('.crawl4ai_tools', 'Crawl4AITools'), 'crawl4ai_tools': ('.crawl4ai_tools', None), + # Langextract Tools (interactive text analysis) + 'langextract_extract': ('.langextract_tools', None), + 'langextract_render_file': ('.langextract_tools', None), + # Unified Web Search (auto-fallback across providers) 'search_web': ('.web_search', None), 'web_search': ('.web_search', None), # Alias diff --git a/src/praisonai-agents/praisonaiagents/tools/langextract_tools.py b/src/praisonai-agents/praisonaiagents/tools/langextract_tools.py new file mode 100644 index 000000000..f60a032ea --- /dev/null +++ b/src/praisonai-agents/praisonaiagents/tools/langextract_tools.py @@ -0,0 +1,251 @@ +"""Langextract tools for interactive text analysis and extraction. + +Provides first-class tool integration for langextract functionality, +allowing agents to create interactive HTML visualizations from text. + +Usage: + from praisonaiagents.tools import langextract_extract + + # Agent can call this tool directly + result = langextract_extract( + text="The quick brown fox jumps over the lazy dog.", + extractions=["fox", "dog"] + ) + +Architecture: + - Follows AGENTS.md tool patterns (decorator-based, lazy imports) + - Protocol-driven design with optional dependencies + - Zero overhead when langextract is not installed +""" + +from typing import List, Optional, Dict, Any +from ..approval import require_approval +from .decorator import tool + + +@tool +def langextract_extract( + text: str, + extractions: Optional[List[str]] = None, + document_id: str = "agent-analysis", + output_path: Optional[str] = None, + auto_open: bool = False +) -> Dict[str, Any]: + """Extract and annotate text using langextract for interactive visualization. + + Creates an interactive HTML document with highlighted extractions that can be + viewed in a browser. Useful for text analysis, entity extraction, and + document annotation workflows. + + Args: + text: The source text to analyze and extract from + extractions: List of text snippets to highlight in the document + document_id: Identifier for the document (used in HTML output) + output_path: Path to save HTML file (defaults to temp file) + auto_open: Whether to automatically open the HTML file in browser + + Returns: + Dict containing: + - html_path: Path to the generated HTML file + - extractions_count: Number of extractions processed + - document_id: The document identifier used + - success: True if successful, False otherwise + - error: Error message if success is False + + Raises: + ImportError: If langextract is not installed + ValueError: If text is empty or invalid + """ + if not text or not text.strip(): + return { + "success": False, + "error": "Text cannot be empty", + "html_path": None, + "extractions_count": 0, + "document_id": document_id + } + + try: + # Lazy import langextract (optional dependency) + try: + import langextract as lx # type: ignore + except ImportError: + return { + "success": False, + "error": "langextract is not installed. Install with: pip install langextract", + "html_path": None, + "extractions_count": 0, + "document_id": document_id + } + + # Process extractions if provided + extractions_list = extractions or [] + extraction_objects = [] + added_count = 0 + + for i, extraction_text in enumerate(extractions_list): + if not extraction_text.strip(): + continue + + # Find all occurrences of the extraction in the text + start_pos = 0 + while True: + pos = text.lower().find(extraction_text.lower(), start_pos) + if pos == -1: + break + + # Create extraction with proper CharInterval + extraction = lx.data.Extraction( + extraction_class=f"extraction_{i}", + extraction_text=extraction_text, + char_interval=lx.data.CharInterval( + start_pos=pos, + end_pos=pos + len(extraction_text) + ), + attributes={ + "index": i, + "original_text": extraction_text, + "tool": "langextract_extract" + } + ) + extraction_objects.append(extraction) + added_count += 1 + start_pos = pos + 1 + + # Create AnnotatedDocument with extractions + document = lx.data.AnnotatedDocument( + document_id=document_id, + text=text, + extractions=extraction_objects + ) + + # Determine output path + if not output_path: + import tempfile + import os + output_path = os.path.join( + tempfile.gettempdir(), + f"langextract_{document_id}.html" + ) + + # Save as JSONL first, then render HTML + import tempfile + import os + + # Create temporary JSONL file + jsonl_dir = tempfile.gettempdir() + jsonl_path = os.path.join(jsonl_dir, f"langextract_{document_id}.jsonl") + + lx.io.save_annotated_documents( + [document], + output_name=os.path.basename(jsonl_path), + output_dir=jsonl_dir + ) + + # Generate HTML using visualize + html = lx.visualize(jsonl_path) + html_content = html.data if hasattr(html, 'data') else html + + # Write HTML file + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + # Clean up temporary JSONL + try: + os.remove(jsonl_path) + except OSError: + pass + + # Auto-open if requested + if auto_open: + import webbrowser + from pathlib import Path + webbrowser.open(Path(output_path).resolve().as_uri()) + + return { + "success": True, + "html_path": output_path, + "extractions_count": added_count, + "document_id": document_id, + "error": None + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "html_path": None, + "extractions_count": 0, + "document_id": document_id + } + + +@tool +@require_approval(risk_level="high") +def langextract_render_file( + file_path: str, + extractions: Optional[List[str]] = None, + output_path: Optional[str] = None, + auto_open: bool = False +) -> Dict[str, Any]: + """Read a text file and create langextract visualization. + + Reads a text file from disk and creates an interactive HTML visualization + with optional extractions highlighted. + + Args: + file_path: Path to the text file to read + extractions: List of text snippets to highlight + output_path: Path to save HTML file (defaults to same dir as input) + auto_open: Whether to automatically open the HTML file in browser + + Returns: + Dict with same structure as langextract_extract + + Raises: + FileNotFoundError: If file_path does not exist + ImportError: If langextract is not installed + """ + import os + + if not os.path.exists(file_path): + return { + "success": False, + "error": f"File not found: {file_path}", + "html_path": None, + "extractions_count": 0, + "document_id": os.path.basename(file_path) + } + + try: + # Read file content + with open(file_path, 'r', encoding='utf-8') as f: + text = f.read() + + # Default output path to same directory as input + if not output_path: + base_name = os.path.splitext(os.path.basename(file_path))[0] + output_dir = os.path.dirname(file_path) + output_path = os.path.join(output_dir, f"{base_name}_annotated.html") + + # Use the main extract function + return langextract_extract( + text=text, + extractions=extractions, + document_id=os.path.basename(file_path), + output_path=output_path, + auto_open=auto_open + ) + + except Exception as e: + return { + "success": False, + "error": str(e), + "html_path": None, + "extractions_count": 0, + "document_id": os.path.basename(file_path) + } + + +# Export for direct import +__all__ = ["langextract_extract", "langextract_render_file"] \ No newline at end of file diff --git a/src/praisonai-agents/tests/unit/test_langextract_tools.py b/src/praisonai-agents/tests/unit/test_langextract_tools.py new file mode 100644 index 000000000..20399a63e --- /dev/null +++ b/src/praisonai-agents/tests/unit/test_langextract_tools.py @@ -0,0 +1,130 @@ +"""Tests for langextract tools.""" + +import tempfile +import os +import sys +import pytest +from unittest.mock import patch, MagicMock +from praisonaiagents.approval import set_approval_callback, ApprovalDecision + + +@pytest.fixture(autouse=True) +def _auto_approve_high_risk(): + """Auto-approve high-risk tools so tests don't block on stdin.""" + original = set_approval_callback(lambda *a, **kw: ApprovalDecision(approved=True)) + yield + set_approval_callback(original) + + +def test_langextract_extract_smoke_import(): + """Test that langextract_extract can be imported without langextract installed.""" + from praisonaiagents.tools.langextract_tools import langextract_extract + assert langextract_extract is not None + + +def test_langextract_extract_missing_dependency(): + """Test behavior when langextract is not installed.""" + from praisonaiagents.tools.langextract_tools import langextract_extract + + with patch.dict('sys.modules', {'langextract': None}): + with patch('builtins.__import__', side_effect=ImportError("No module named 'langextract'")): + result = langextract_extract("test text", ["test"]) + + assert result["success"] is False + assert "langextract is not installed" in result["error"] + assert result["html_path"] is None + assert result["extractions_count"] == 0 + + +def test_langextract_extract_empty_text(): + """Test behavior with empty text input.""" + from praisonaiagents.tools.langextract_tools import langextract_extract + + result = langextract_extract("", ["test"]) + + assert result["success"] is False + assert "Text cannot be empty" in result["error"] + assert result["html_path"] is None + assert result["extractions_count"] == 0 + + +def test_langextract_extract_with_mock_langextract(monkeypatch): + """Test successful extraction with mocked langextract.""" + # Mock langextract module + mock_lx = MagicMock() + mock_lx.data.CharInterval = MagicMock() + mock_lx.data.Extraction = MagicMock() + mock_lx.data.AnnotatedDocument = MagicMock() + mock_lx.io.save_annotated_documents = MagicMock() + mock_lx.visualize = MagicMock() + + # Mock HTML response + mock_html = MagicMock() + mock_html.data = "test" + mock_lx.visualize.return_value = mock_html + + # Use monkeypatch to set the mock module in sys.modules + monkeypatch.setitem(sys.modules, "langextract", mock_lx) + + from praisonaiagents.tools.langextract_tools import langextract_extract + + # Mock file operations + with patch('builtins.open', create=True) as mock_open: + with patch('os.remove'): + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + result = langextract_extract( + text="The quick brown fox jumps", + extractions=["fox", "quick"], + document_id="test-doc" + ) + + assert result["success"] is True + assert result["document_id"] == "test-doc" + assert result["error"] is None + # Should count actual extractions found (2: "fox" once, "quick" once) + assert result["extractions_count"] >= 0 + + +def test_langextract_render_file_missing_file(): + """Test behavior when file doesn't exist.""" + from praisonaiagents.tools.langextract_tools import langextract_render_file + + result = langextract_render_file("/nonexistent/file.txt") + + assert result["success"] is False + assert "File not found" in result["error"] + assert result["html_path"] is None + assert result["extractions_count"] == 0 + + +@patch('os.path.exists') +@patch('builtins.open') +def test_langextract_render_file_delegates_to_extract(mock_open, mock_exists): + """Test that render_file delegates to langextract_extract.""" + from praisonaiagents.tools.langextract_tools import langextract_render_file + + mock_exists.return_value = True + mock_file = MagicMock() + mock_file.read.return_value = "test file content" + mock_open.return_value.__enter__.return_value = mock_file + + with patch('praisonaiagents.tools.langextract_tools.langextract_extract') as mock_extract: + mock_extract.return_value = {"success": True, "delegated": True} + + result = langextract_render_file("/test/file.txt", ["test"]) + + assert result["delegated"] is True + mock_extract.assert_called_once() + # Verify it called extract with file content + args, kwargs = mock_extract.call_args + assert kwargs["text"] == "test file content" + + +if __name__ == "__main__": + test_langextract_extract_smoke_import() + test_langextract_extract_missing_dependency() + test_langextract_extract_empty_text() + test_langextract_render_file_missing_file() + print("All basic tests passed!") \ No newline at end of file diff --git a/src/praisonai/praisonai/cli/app.py b/src/praisonai/praisonai/cli/app.py index c5722c706..fabbfd96e 100644 --- a/src/praisonai/praisonai/cli/app.py +++ b/src/praisonai/praisonai/cli/app.py @@ -14,17 +14,26 @@ def _setup_langfuse_observability(*, verbose: bool = False) -> None: - """Set up Langfuse observability by wiring TraceSink to action emitter.""" + """Set up Langfuse observability by wiring both Action and Context emitters.""" try: from praisonai.observability.langfuse import LangfuseSink from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter + from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter + import atexit # Create LangfuseSink (auto-reads env vars) sink = LangfuseSink() - # Set up action-level trace emitter (sufficient for Phase 1) - emitter = TraceEmitter(sink=sink, enabled=True) - set_default_emitter(emitter) + # Set up action-level trace emitter (for backward compatibility) + action_emitter = TraceEmitter(sink=sink, enabled=True) + set_default_emitter(action_emitter) + + # Set up context-level trace emitter (captures rich agent lifecycle events) + context_emitter = ContextTraceEmitter(sink=sink.context_sink(), enabled=True) + set_context_emitter(context_emitter) + + # Clean up on exit + atexit.register(sink.close) except ImportError: # Gracefully degrade if Langfuse not installed diff --git a/src/praisonai/praisonai/observability/langfuse.py b/src/praisonai/praisonai/observability/langfuse.py index 8d9d34856..f7c17c916 100644 --- a/src/praisonai/praisonai/observability/langfuse.py +++ b/src/praisonai/praisonai/observability/langfuse.py @@ -17,6 +17,82 @@ from typing import Any, Dict, Optional from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol +from praisonaiagents.trace.context_events import ContextEvent, ContextEventType, ContextTraceSinkProtocol + + +class _ContextToActionBridge: + """ + Bridge adapter that implements ContextTraceSinkProtocol and forwards + ContextEvents to ActionEvents for LangfuseSink. + + This enables LangfuseSink to receive events from ContextTraceEmitter, + which captures the rich lifecycle events that actual agents emit. + """ + + __slots__ = ("_action_sink",) + + def __init__(self, action_sink: "LangfuseSink") -> None: + self._action_sink = action_sink + + def emit(self, event: ContextEvent) -> None: + """Convert ContextEvent to ActionEvent and forward to action sink.""" + action_event = self._convert_context_to_action(event) + if action_event: + self._action_sink.emit(action_event) + + def flush(self) -> None: + """Forward flush to action sink.""" + self._action_sink.flush() + + def close(self) -> None: + """Forward close to action sink.""" + self._action_sink.close() + + def _convert_context_to_action(self, ctx_event: ContextEvent) -> Optional[ActionEvent]: + """Convert ContextEvent to ActionEvent format.""" + # Map ContextEventType to ActionEventType + event_type_mapping = { + ContextEventType.AGENT_START: ActionEventType.AGENT_START, + ContextEventType.AGENT_END: ActionEventType.AGENT_END, + ContextEventType.TOOL_CALL_START: ActionEventType.TOOL_START, + ContextEventType.TOOL_CALL_END: ActionEventType.TOOL_END, + ContextEventType.LLM_REQUEST: None, # No direct ActionEvent equivalent + ContextEventType.LLM_RESPONSE: None, # No direct ActionEvent equivalent + ContextEventType.MESSAGE_ADDED: None, # No direct ActionEvent equivalent + ContextEventType.SESSION_START: None, # No direct ActionEvent equivalent + ContextEventType.SESSION_END: None, # No direct ActionEvent equivalent + } + + action_type = event_type_mapping.get(ctx_event.event_type) + if not action_type: + return None + + event_data = ctx_event.data if isinstance(ctx_event.data, dict) else {} + tool_result_summary = event_data.get("tool_result_summary") + if tool_result_summary is None: + tool_result_summary = event_data.get("result") + + status = event_data.get("status") + error_message = event_data.get("error_message") + if error_message is None: + error_message = event_data.get("error") + if status is None and action_type == ActionEventType.TOOL_END: + status = "error" if error_message else "completed" + + # Convert to ActionEvent format + return ActionEvent( + event_type=action_type.value, + timestamp=ctx_event.timestamp, + agent_id=ctx_event.session_id, + agent_name=ctx_event.agent_name or "unknown", + tool_name=event_data.get("tool_name"), + tool_args=event_data.get("tool_args"), + tool_result_summary=tool_result_summary, + duration_ms=event_data.get("duration_ms", 0.0), + status=status, + error_message=error_message, + metadata=event_data, + ) @dataclass @@ -303,4 +379,17 @@ def close(self) -> None: self._spans.clear() self._traces.clear() except Exception: - pass \ No newline at end of file + pass + + def context_sink(self) -> ContextTraceSinkProtocol: + """ + Get a ContextTraceSinkProtocol bridge that forwards ContextEvents to this sink. + + This enables LangfuseSink to receive events from ContextTraceEmitter, + which captures the rich lifecycle events that actual agents emit + (agent_start, agent_end, tool_call_start, tool_call_end, llm_request, llm_response). + + Returns: + ContextTraceSinkProtocol: Bridge that converts and forwards events + """ + return _ContextToActionBridge(self) diff --git a/src/praisonai/tests/unit/test_langfuse_sink.py b/src/praisonai/tests/unit/test_langfuse_sink.py index c27bbc4ad..28cc77e24 100644 --- a/src/praisonai/tests/unit/test_langfuse_sink.py +++ b/src/praisonai/tests/unit/test_langfuse_sink.py @@ -16,6 +16,7 @@ import pytest +from praisonaiagents.trace.context_events import ContextEvent, ContextEventType from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceSinkProtocol from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig @@ -306,3 +307,47 @@ def test_implements_trace_sink_protocol(self): """LangfuseSink satisfies TraceSinkProtocol at runtime.""" sink = LangfuseSink(LangfuseSinkConfig(enabled=False)) assert isinstance(sink, TraceSinkProtocol) + + +class TestLangfuseContextBridge: + def test_context_tool_end_maps_result_and_status(self): + sink = _make_sink_with_mock_client() + bridge = sink.context_sink() + + event = ContextEvent( + event_type=ContextEventType.TOOL_CALL_END, + timestamp=time.time(), + session_id="session-1", + agent_name="agent1", + data={ + "tool_name": "search_tool", + "result": "ok", + "duration_ms": 12.0, + }, + ) + + action_event = bridge._convert_context_to_action(event) + assert action_event is not None + assert action_event.tool_result_summary == "ok" + assert action_event.status == "completed" + assert action_event.error_message is None + + def test_context_tool_end_maps_error(self): + sink = _make_sink_with_mock_client() + bridge = sink.context_sink() + + event = ContextEvent( + event_type=ContextEventType.TOOL_CALL_END, + timestamp=time.time(), + session_id="session-1", + agent_name="agent1", + data={ + "tool_name": "search_tool", + "error": "failed", + }, + ) + + action_event = bridge._convert_context_to_action(event) + assert action_event is not None + assert action_event.status == "error" + assert action_event.error_message == "failed"