""" Honcho Test Runner A script that executes JSON-formatted tests against a running Honcho instance. This script: 1. Loads test definitions from JSON files 2. Creates a workspace for each test 3. Adds all messages to sessions 4. Waits for the deriver queue to be empty 5. Executes queries and judges responses using an LLM """ import argparse import asyncio import json import logging import os import sys import time from pathlib import Path from typing import Any import tiktoken from anthropic import AsyncAnthropic from dotenv import load_dotenv from honcho import Honcho from honcho.session import SessionPeerConfig from typing_extensions import TypedDict load_dotenv() class SessionResult(TypedDict): """Type definition for session creation results.""" name: str message_count: int class QueryResult(TypedDict): """Type definition for query execution results.""" query: str expected_response: str actual_response: str session: str | None observer: str | None target: str | None judgment: dict[str, Any] class TestResult(TypedDict): """Type definition for test execution results.""" test_name: str workspace_id: str sessions_created: list[SessionResult] queries_executed: list[QueryResult] passed: bool error: str | None start_time: float end_time: float duration_seconds: float class TestRunner: """ Executes JSON tests against a Honcho instance. """ def __init__( self, honcho_url: str = "http://localhost:8000", anthropic_api_key: str | None = None, ): """ Initialize the test runner. Args: honcho_url: URL of the running Honcho instance anthropic_api_key: Anthropic API key for judging responses """ self.honcho_url: str = honcho_url self.anthropic_api_key: str | None = anthropic_api_key # Configure logging logging.basicConfig( level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" ) self.logger: logging.Logger = logging.getLogger(__name__) # Suppress HTTP request logs from the Honcho SDK logging.getLogger("httpx").setLevel(logging.ERROR) logging.getLogger("httpcore").setLevel(logging.ERROR) if self.anthropic_api_key: self.anthropic_client: AsyncAnthropic = AsyncAnthropic( api_key=self.anthropic_api_key ) else: api_key = os.getenv("LLM_ANTHROPIC_API_KEY") if not api_key: raise ValueError("LLM_ANTHROPIC_API_KEY is not set") self.anthropic_client = AsyncAnthropic(api_key=api_key) def load_test_file(self, test_file: Path) -> dict[str, Any]: """ Load a test definition from a JSON file. Args: test_file: Path to the JSON test file Returns: Test definition dictionary """ with open(test_file) as f: return json.load(f) def create_honcho_client(self, workspace_id: str) -> Honcho: """ Create a Honcho client for a specific workspace. Args: workspace_id: Workspace ID for the test Returns: Honcho client instance """ return Honcho( environment="local", workspace_id=workspace_id, base_url=self.honcho_url ) async def wait_for_deriver_queue_empty( self, honcho_client: Honcho, session_id: str | None = None ) -> bool: """ Wait for the deriver queue to be empty. Args: honcho_client: Honcho client instance timeout: Maximum time to wait in seconds Returns: True if queue is empty, False if timeout exceeded """ time.sleep(1) try: while True: status = honcho_client.get_deriver_status(session_id=session_id) if ( status.in_progress_work_units == 0 and status.pending_work_units == 0 ): break time.sleep(0.5) return True except Exception as e: self.logger.warning(f"Error polling deriver status: {e}") return False async def judge_response( self, query: str, expected_response: str, actual_response: str ) -> dict[str, Any]: """ Use an LLM to judge if the actual response matches the expected response. Args: query: The original query expected_response: Expected response from the test actual_response: Actual response from Honcho Returns: Judgment result with pass/fail and reasoning """ try: system_prompt = """ You are an expert judge evaluating AI responses. Your task is to determine if an actual response contains the core correct information from an expected response. CRITICAL JUDGING PRINCIPLES: 1. SEMANTIC UNDERSTANDING: Focus on whether the actual response conveys the same core factual information as expected, even if expressed differently 2. FLEXIBLE INTERPRETATION: Accept responses that are longer, more detailed, or use different phrasing as long as they contain the correct core facts 3. CONTEXTUAL REASONING: If the response shows logical reasoning that leads to the correct conclusion, consider it correct even if the path differs 4. CONFLICTING INFORMATION: If a response acknowledges conflicts but correctly identifies the most recent/authoritative information, that should pass 5. IMPLICIT vs EXPLICIT: Accept responses that imply the correct answer through reasoning, not just explicit statements ONLY FAIL when: - Core factual information is demonstrably wrong - The response contradicts the expected information without justification - Essential information is completely missing with no reasonable inference path Always respond with valid JSON: {"passed": boolean, "reasoning": "short (1-3 sentences) explanation of why the response is correct or incorrect"}""" user_prompt = f"""Query: "{query}" Expected response: "{expected_response}" Actual response: "{actual_response}" Evaluate whether the actual response contains the core correct information from the expected response. Focus on semantic meaning and logical conclusions, not exact phrasing. """ response = await self.anthropic_client.messages.create( model="claude-3-7-sonnet-20250219", max_tokens=300, temperature=0.0, system=system_prompt, messages=[ { "role": "user", "content": user_prompt, } ], ) if not response.content: raise ValueError("Anthropic returned empty response") content_block = response.content[0] judgment_text = getattr(content_block, "text", None) if judgment_text is None: raise ValueError( f"No text content in response block: {type(content_block)}" ) if judgment_text is None: raise ValueError("Anthropic returned empty response") # Extract JSON from the response if it's wrapped in markdown if "```json" in judgment_text: json_start = judgment_text.find("```json") + 7 json_end = judgment_text.find("```", json_start) judgment_text = judgment_text[json_start:json_end].strip() elif "```" in judgment_text: json_start = judgment_text.find("```") + 3 json_end = judgment_text.find("```", json_start) judgment_text = judgment_text[json_start:json_end].strip() judgment = json.loads(judgment_text) return judgment except Exception as e: self.logger.error(f"Error judging response: {e}") # Fallback to simple string matching is_correct = expected_response.lower() in actual_response.lower() return { "passed": is_correct, "reasoning": f"Fallback string matching due to error: {'Match found' if is_correct else 'No match found'}", } async def execute_test(self, test_file: Path) -> TestResult: """ Execute a single test file. Args: test_file: Path to the JSON test file Returns: Test execution results """ test_name = test_file.stem print(f"\033[1mExecuting test {test_name}\033[0m") # Load test definition test_def = self.load_test_file(test_file) # Create workspace for this test workspace_id = f"test_{test_name}_{int(time.time())}" honcho_client = self.create_honcho_client(workspace_id) results: TestResult = { "test_name": test_name, "workspace_id": workspace_id, "sessions_created": [], "queries_executed": [], "passed": False, "error": None, "start_time": time.time(), "end_time": 0.0, "duration_seconds": 0.0, } try: # Step 1: Create sessions and add messages sessions = test_def.get("sessions", {}) # Collect all unique peers from messages and queries all_peers: set[str] = set() for session_data in sessions.values(): for msg in session_data.get("messages", []): all_peers.add(msg["peer"]) # Get queries and collect peers from them queries: list[dict[str, Any]] = test_def.get("queries", []) for query_dict in queries: if "observer" in query_dict: all_peers.add(str(query_dict["observer"])) if "target" in query_dict: all_peers.add(str(query_dict["target"])) # Identify peers that are observers in queries (need observe_others=True) observer_peers: set[str] = set() for query_dict in queries: if "observer" in query_dict and "target" in query_dict: observer_peers.add(str(query_dict["observer"])) # for efficiency, identify peers that are never targets in any query # so we can turn off their observe_me flag observed_peers: set[str] = set() for query_dict in queries: if "target" in query_dict: observed_peers.add(str(query_dict["target"])) if "observer" in query_dict and "target" not in query_dict: observed_peers.add(str(query_dict["observer"])) non_observed_peers: set[str] = all_peers - observed_peers # Create all peers first peers: dict[str, Any] = {} for peer_name in all_peers: peers[peer_name] = honcho_client.peer(id=peer_name) for session_name, session_data in sessions.items(): # Create session session = honcho_client.session(id=str(session_name)) print(f"\n session: {session_name}") # Create peer configurations based on requirements peer_configs: list[tuple[Any, SessionPeerConfig]] = [] for peer_name in all_peers: # If this peer is an observer in any *targeted* query, they need to observe others if peer_name in observer_peers: if peer_name in non_observed_peers: config = SessionPeerConfig( observe_me=False, observe_others=True ) peer_configs.append((peers[peer_name], config)) print(f" peer config: {peer_name} -> {config}") else: config = SessionPeerConfig( observe_me=True, observe_others=True ) peer_configs.append((peers[peer_name], config)) print(f" peer config: {peer_name} -> {config}") elif peer_name in observed_peers: config = SessionPeerConfig( observe_me=True, observe_others=False ) peer_configs.append((peers[peer_name], config)) print(f" peer config: {peer_name} -> {config}") else: config = SessionPeerConfig( observe_me=False, observe_others=False ) peer_configs.append((peers[peer_name], config)) print(f" peer config: {peer_name} -> {config}") session.add_peers(peer_configs) # Add messages to session messages = session_data.get("messages", []) for msg in messages: peer_name: str = msg["peer"] content: str = msg["content"] truncated_content = ( content[:140] + "..." if len(content) > 140 else content ) print(f" {peer_name}: {truncated_content}") # Add messages to session session.add_messages( [peers[msg["peer"]].message(msg["content"]) for msg in messages] ) results["sessions_created"].append( SessionResult(name=str(session_name), message_count=len(messages)) ) # Step 2: Execute queries all_queries_passed = True for i, query_data in enumerate(queries): query: str = query_data["query"] expected_response: str = query_data["expected_response"] session_name: str | None = query_data.get("session") observer_name: str | None = query_data.get("observer") target_name: str | None = query_data.get("target") # Wait for deriver queue to be empty for this session queue_empty = await self.wait_for_deriver_queue_empty( honcho_client, session_id=session_name ) if not queue_empty: print(f"Deriver queue never emptied for session {session_name}!!!") sys.exit(1) print(f"\n query {i + 1}: {query}") context_parts: list[str] = [] if session_name: context_parts.append(f"session: {session_name}") if observer_name: context_parts.append(f"observer: {observer_name}") if target_name: context_parts.append(f"target: {target_name}") if context_parts: print(" " + ", ".join(context_parts)) try: # Determine which peer to use for the query (observer) if observer_name: query_peer = peers[observer_name] else: # Use the first peer from the first session first_session_data = list(sessions.values())[0] first_peer_name: str = first_session_data["messages"][0]["peer"] query_peer = peers[first_peer_name] # Execute chat query if session_name and target_name: response = query_peer.chat( query, session_id=session_name, target=peers[target_name] ) elif session_name: response = query_peer.chat(query, session_id=session_name) elif target_name: response = query_peer.chat(query, target=peers[target_name]) else: response = query_peer.chat(query) actual_response: str = ( response.content if hasattr(response, "content") else str(response) ) # Judge the response judgment = await self.judge_response( query, expected_response, actual_response ) query_result: QueryResult = { "query": query, "expected_response": expected_response, "actual_response": actual_response, "session": session_name, "observer": observer_name, "target": target_name, "judgment": judgment, } results["queries_executed"].append(query_result) print( " judgment: \033[1m\033[32mPASS\033[0m" if judgment["passed"] else " judgment: \033[1m\033[31mFAIL\033[0m" ) if not judgment["passed"]: # if failed, print print(f" got response: \033[3m{actual_response}\033[0m") print(f" expected: {expected_response}") else: # if passed, just log self.logger.info( f" got response: \033[3m{actual_response}\033[0m" ) self.logger.info(f" expected: {expected_response}") print(f" reasoning: {judgment['reasoning']}") # Track if all queries pass if not judgment["passed"]: all_queries_passed = False except Exception as e: self.logger.error(f"Error executing query {i + 1}: {e}") query_result = QueryResult( query=query, expected_response=expected_response, actual_response=f"ERROR: {e}", session=session_name, observer=observer_name, target=target_name, judgment={ "passed": False, "reasoning": f"Query execution failed: {e}", }, ) results["queries_executed"].append(query_result) all_queries_passed = False # Step 3: Execute get_context calls get_context_calls = test_def.get("get_context_calls", []) for i, get_context_call in enumerate(get_context_calls): print(f"\n get_context call #{i + 1}") session_name = str(get_context_call["session"]) summary = get_context_call["summary"] max_tokens: int | None = get_context_call.get("max_tokens") session = honcho_client.session(id=session_name) # Wait for deriver queue to be empty for this session queue_empty = await self.wait_for_deriver_queue_empty( honcho_client, session_id=session_name ) if not queue_empty: print(f"Deriver queue never emptied for session {session_name}!!!") sys.exit(1) session_context = session.get_context( summary=summary, tokens=max_tokens ) tokenizer = tiktoken.get_encoding("cl100k_base") summary_tokens = len(tokenizer.encode(session_context.summary)) print(f" summary: {session_context.summary}") got_tokens = summary_tokens for message in session_context.messages: got_tokens += message.token_count print(f" max tokens: {max_tokens}") print( f" got token count: {got_tokens} (summary: {summary_tokens}, messages: {got_tokens - summary_tokens} in {len(session_context.messages)} messages)" ) if max_tokens and got_tokens > max_tokens: all_queries_passed = False # Determine if test passed (all queries and get_context calls must pass) results["passed"] = all_queries_passed results["end_time"] = time.time() results["duration_seconds"] = results["end_time"] - results["start_time"] print( f"\nTest {test_name} completed. Status: {'PASS' if results['passed'] else 'FAIL'} (Duration: {results['duration_seconds']:.2f}s)" ) except Exception as e: self.logger.error(f"Error executing test {test_name}: {e}") results["error"] = str(e) results["passed"] = False results["end_time"] = time.time() results["duration_seconds"] = results["end_time"] - results["start_time"] return results async def run_all_tests(self, tests_dir: Path) -> list[TestResult]: """ Run all tests in a directory. Args: tests_dir: Directory containing JSON test files Returns: List of test results """ test_files = list(tests_dir.glob("*.json")) print(f"found {len(test_files)} test files in {tests_dir}") all_results: list[TestResult] = [] for test_file in test_files: result = await self.execute_test(test_file) all_results.append(result) # Print summary for this test print(f"\n{'=' * 60}") print(f"Test: {result['test_name']}") print(f"Status: {'PASS' if result['passed'] else 'FAIL'}") print(f"Duration: {result['duration_seconds']:.2f}s") print(f"{'=' * 60}\n") return all_results def print_summary(self, results: list[TestResult]) -> None: """ Print a summary of all test results. Args: results: List of test results """ print(f"\n{'=' * 80}") print("TEST EXECUTION SUMMARY") print(f"{'=' * 80}") total_tests = len(results) passed_tests = sum(1 for r in results if r.get("passed", False)) failed_tests = total_tests - passed_tests total_test_time = sum(r["duration_seconds"] for r in results) print(f"Total Tests: {total_tests}") print(f"Passed: {passed_tests}") print(f"Failed: {failed_tests}") print(f"Success Rate: {(passed_tests / total_tests) * 100:.1f}%") print(f"Total Test Time: {total_test_time:.2f}s") print("\nDetailed Results:") print(f"{'Test Name':<20} {'Status':<8} {'Duration':<10} {'Workspace ID':<30}") print(f"{'-' * 20} {'-' * 8} {'-' * 10} {'-' * 30}") for result in results: test_name = result["test_name"] status = "PASS" if result.get("passed", False) else "FAIL" duration = f"{result['duration_seconds']:.2f}s" workspace = result["workspace_id"] print(f"{test_name:<20} {status:<8} {duration:<10} {workspace:<30}") print(f"{'=' * 80}") async def main() -> int: """ Main entry point for the test runner. """ parser = argparse.ArgumentParser( description="Run JSON tests against a Honcho instance", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s --tests-dir tests/bench/tests # Run all tests %(prog)s --tests-dir tests/bench/tests --test 1.json # Run specific test %(prog)s --honcho-url http://localhost:8000 # Custom Honcho URL """, ) parser.add_argument( "--tests-dir", type=Path, default=Path("tests/bench/tests"), help="Directory containing JSON test files (default: tests/bench/tests)", ) parser.add_argument( "--test", type=str, help="Run a specific test file by name (e.g., '1.json') (optional)", ) parser.add_argument( "--honcho-url", type=str, default="http://localhost:8000", help="URL of the running Honcho instance (default: http://localhost:8000)", ) parser.add_argument( "--anthropic-api-key", type=str, help="Anthropic API key for response judging (optional)", ) parser.add_argument( "--timeout", type=int, default=60, help="Timeout for deriver queue to empty in seconds (default: 60)", ) args = parser.parse_args() # Validate arguments if args.test: test_file_path = args.tests_dir / args.test if not test_file_path.exists(): print(f"Error: Test file {test_file_path} does not exist") return 1 if not args.tests_dir.exists(): print(f"Error: Tests directory {args.tests_dir} does not exist") return 1 # Create test runner runner = TestRunner( honcho_url=args.honcho_url, anthropic_api_key=args.anthropic_api_key ) try: if args.test: # Run single test test_file_path = args.tests_dir / args.test result = await runner.execute_test(test_file_path) runner.print_summary([result]) else: # Run all tests results = await runner.run_all_tests(args.tests_dir) runner.print_summary(results) return 0 except KeyboardInterrupt: print("\nTest execution interrupted by user") return 1 except Exception as e: print(f"Error running tests: {e}") return 1 if __name__ == "__main__": exit_code = asyncio.run(main()) exit(exit_code)