""" Subagent Tools for SpatialAgent. These tools act as autonomous subagents that perform multi-step analysis tasks, including reading files, interpreting images, and synthesizing comprehensive reports. """ import os import json import base64 import pandas as pd from typing import Annotated from os.path import exists from langchain_core.tools import tool from pydantic import Field # Module-level config (set via configure_subagent_tools) _config = { "save_path": "./experiments", } def configure_subagent_tools(save_path: str = "./experiments"): """Configure paths for subagent tools. Call this before using the tools.""" _config["save_path"] = save_path # Default model for subagent LLM calls (fallback if agent model not set) DEFAULT_MODEL = "claude-sonnet-4-5-20250929" def _get_subagent_model() -> str: """Get the model to use for subagent calls. Uses the main agent's model if available, otherwise falls back to DEFAULT_MODEL. Handles cases where model name resolution failed (returns "unknown"). """ try: from ..agent import get_agent_model model = get_agent_model() # Fallback if model is empty or resolution failed ("unknown") if not model or model == "unknown": return DEFAULT_MODEL return model except ImportError: return DEFAULT_MODEL def _resize_image_if_needed(image_path: str, max_size_bytes: int = 4_000_000) -> bytes: """Resize image if file size exceeds API limit. Claude API has a 5MB per-image limit. We resize to stay under that. File size scales roughly with pixel count, so we reduce dimensions proportionally. Args: image_path: Path to the image file max_size_bytes: Maximum file size in bytes (default 4MB for safety margin) Returns: Image bytes (resized if necessary, original if not) """ from PIL import Image import io import math with open(image_path, "rb") as f: original_bytes = f.read() if len(original_bytes) <= max_size_bytes: return original_bytes # Need to resize - scale down proportionally with Image.open(image_path) as img: scale = math.sqrt(max_size_bytes / len(original_bytes)) new_size = (max(int(img.width * scale), 100), max(int(img.height * scale), 100)) resized = img.resize(new_size, Image.Resampling.LANCZOS) buffer = io.BytesIO() resized.save(buffer, format=img.format or 'PNG') print(f"Resized image: {img.width}x{img.height} -> {new_size[0]}x{new_size[1]}") return buffer.getvalue() # ============================================================================= # Deep Research Report Subagent # ============================================================================= @tool def report_subagent( user_query: Annotated[str, Field(description="The research question being investigated (e.g., 'How does cellular composition change during disease progression?')")], data_info: Annotated[str, Field(description="Brief dataset description including species, tissue, technology, and conditions (e.g., 'MERFISH spatial transcriptomics of mouse colon, 4 disease stages')")], save_path: Annotated[str, Field(description="Directory path containing analysis outputs (figures, CSVs, observation_log.jsonl)")] = None, ) -> str: """Generate a publication-quality research report by analyzing all saved artifacts. IMPORTANT: Call this tool with exactly 3 parameters as keyword arguments. Example usage: report_subagent( user_query="How does the cellular microenvironment change during colitis?", data_info="MERFISH mouse colon, 50k cells, 4 disease stages (Healthy, DSS3, DSS9, DSS21)", save_path="./experiments/my_analysis/" ) What this tool does automatically: 1. Reads observation_log.jsonl from save_path for analysis history 2. Discovers and interprets ALL figures (PNG/JPG) using vision LLM 3. Reads and summarizes CSV data tables for statistics 4. Extracts insights from JSON summary files 5. Generates a comprehensive markdown report with: - Executive summary - Introduction and background - Methods documentation - Results with figure references - Discussion and interpretation - Conclusions and future directions Output: Saves report to {save_path}/deep_research_report.md Note: Do NOT pass dictionaries or extra parameters like 'key_findings', 'figure_descriptions', or 'methods_summary' - these are auto-discovered. Returns: str: Summary of report generation with file path """ from ..agent import make_llm import glob save_path = save_path or _config["save_path"] print("🔬 Starting Deep Research Report Subagent...") model = _get_subagent_model() print(f" Using model: {model}") llm = make_llm(model, stop_sequences=[], max_tokens=16384) # Higher limit for long reports output_path = f"{save_path}/deep_research_report.md" # ========================================================================= # PASS 1: Inventory all artifacts # ========================================================================= print("📋 Pass 1: Inventorying all artifacts...") # Read accumulated observations observation_log_path = f"{save_path}/observation_log.jsonl" observations = [] if exists(observation_log_path): with open(observation_log_path, 'r') as f: for line in f: try: observations.append(json.loads(line.strip())) except: pass print(f" Found {len(observations)} logged observations") # Discover all files figures = [] for ext in ['*.png', '*.jpg', '*.jpeg', '*.svg']: figures.extend(glob.glob(f"{save_path}/**/{ext}", recursive=True)) figures = sorted(set(figures)) print(f" Found {len(figures)} figures") csv_files = sorted(glob.glob(f"{save_path}/**/*.csv", recursive=True)) print(f" Found {len(csv_files)} CSV files") json_files = [f for f in sorted(glob.glob(f"{save_path}/**/*.json", recursive=True)) if 'observation_log' not in f] print(f" Found {len(json_files)} JSON files") # ========================================================================= # PASS 2: Interpret ALL figures with vision LLM # ========================================================================= print(f"🖼️ Pass 2: Interpreting {min(len(figures), 15)} figures with vision LLM...") figure_analyses = {} for i, fig_path in enumerate(figures[:15]): # Limit to 15 most important figures fig_name = os.path.basename(fig_path) print(f" Analyzing {fig_name}...") try: # Resize if needed image_bytes = _resize_image_if_needed(fig_path) image_b64 = base64.b64encode(image_bytes).decode("utf-8") ext = fig_path.lower().split('.')[-1] mime_type = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg'}.get(ext, 'image/png') messages = [ {"role": "user", "content": [ {"type": "text", "text": f"""Analyze this scientific figure from a spatial transcriptomics analysis. Figure filename: {fig_name} Research context: {user_query[:500]} Dataset: {data_info} Provide a concise analysis (200-300 words) covering: 1. What the figure shows (plot type, axes, coloring) 2. Key patterns or findings visible 3. Biological interpretation 4. Main takeaway for the research question"""}, {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_b64}"}} ]} ] response = llm.invoke(messages) figure_analyses[fig_name] = response.content except Exception as e: figure_analyses[fig_name] = f"Could not analyze: {str(e)}" # ========================================================================= # PASS 3: Analyze CSV data tables # ========================================================================= print(f"📊 Pass 3: Analyzing {min(len(csv_files), 10)} CSV data tables...") csv_summaries = {} for csv_path in csv_files[:10]: # Limit to 10 most important csv_name = os.path.basename(csv_path) print(f" Reading {csv_name}...") try: df = pd.read_csv(csv_path) summary = f"**{csv_name}**\n" summary += f"- Shape: {df.shape[0]} rows × {df.shape[1]} columns\n" summary += f"- Columns: {', '.join(df.columns[:10])}" if len(df.columns) > 10: summary += f" ... (+{len(df.columns)-10} more)" summary += "\n" # Add numeric summary for key columns numeric_cols = df.select_dtypes(include=['number']).columns[:5] if len(numeric_cols) > 0: summary += f"- Key statistics:\n" for col in numeric_cols: summary += f" - {col}: mean={df[col].mean():.3f}, std={df[col].std():.3f}\n" # Sample first few rows summary += f"- Sample data:\n```\n{df.head(3).to_string()}\n```\n" csv_summaries[csv_name] = summary except Exception as e: csv_summaries[csv_name] = f"Could not read: {str(e)}" # ========================================================================= # PASS 4: Read JSON summaries # ========================================================================= print(f"📄 Pass 4: Reading JSON summaries...") json_summaries = {} for json_file in json_files[:5]: try: with open(json_file, 'r') as f: content = json.load(f) content_str = json.dumps(content, indent=2) if len(content_str) > 3000: content_str = content_str[:3000] + "\n... (truncated)" json_summaries[os.path.basename(json_file)] = content_str except: pass # ========================================================================= # PASS 5: Format observation history # ========================================================================= print(f"📝 Pass 5: Formatting observation history...") observation_narrative = [] for obs in observations: step_text = f"**Step {obs.get('step', '?')}:**\n" step_text += f"Code: `{obs.get('code_snippet', 'N/A')[:200]}...`\n" result = obs.get('result_summary', '') if result: step_text += f"Result: {result[:500]}...\n" if len(result) > 500 else f"Result: {result}\n" fig_interp = obs.get('figure_interpretations', '') if fig_interp: step_text += f"Figure insights: {fig_interp[:300]}...\n" if len(fig_interp) > 300 else f"Figure insights: {fig_interp}\n" observation_narrative.append(step_text) # ========================================================================= # PASS 6: Generate comprehensive report # ========================================================================= print(f"📝 Pass 6: Generating comprehensive report...") # Build comprehensive context figure_section = "\n\n".join([f"### {name}\n{analysis}" for name, analysis in figure_analyses.items()]) csv_section = "\n\n".join(csv_summaries.values()) json_section = "\n\n".join([f"**{k}:**\n```json\n{v}\n```" for k, v in json_summaries.items()]) obs_section = "\n".join(observation_narrative[:20]) # Limit to 20 steps report_prompt = f"""You are a senior computational biologist writing a comprehensive research report. ## RESEARCH QUESTION {user_query} ## DATASET {data_info} ## ANALYSIS HISTORY ({len(observations)} steps performed) {obs_section} ## FIGURE ANALYSES (Vision LLM interpretations) {figure_section[:8000]} ## DATA TABLE SUMMARIES {csv_section[:3000]} ## JSON SUMMARIES {json_section[:2000]} --- Based on ALL the above information, write a comprehensive, publication-quality research report (5000-7000 words). Structure your report as follows: # Deep Research Report: [Create an informative title based on the research question] ## Executive Summary (3-5 bullet points of the most important findings) ## 1. Introduction ### 1.1 Background (Relevant biological context - what is known about this system/disease/tissue) ### 1.2 Research Question (Clearly state the research objectives) ### 1.3 Significance (Why this research matters) ## 2. Data Overview ### 2.1 Dataset Description (Technology, species, tissue, sample sizes, conditions) ### 2.2 Data Quality (Any quality considerations noted during analysis) ## 3. Methods Summary ### 3.1 Preprocessing ### 3.2 Analysis Pipeline (List the key analytical approaches used) ## 4. Results ### 4.1 [First Major Finding Category] (Describe findings, reference specific figures by filename) ### 4.2 [Second Major Finding Category] (Continue for each major result area) ### 4.3 [Continue as needed] ## 5. Discussion ### 5.1 Key Insights (What do these results tell us biologically?) ### 5.2 Comparison to Literature (How do these findings relate to known biology?) ### 5.3 Mechanistic Interpretation (What biological mechanisms might explain the observations?) ## 6. Conclusions (Summarize the main conclusions that address the original research question) ## 7. Limitations (What are the caveats and limitations of this analysis?) ## 8. Future Directions (What follow-up experiments or analyses would be valuable?) ## 9. Methods Details (Technical details for reproducibility) --- Write the complete report now. Be specific, cite the actual figures generated, and provide biological interpretation.""" messages = [ {"role": "system", "content": "You are an expert computational biologist writing a comprehensive research report. Write in clear, scientific prose. Be specific and detailed."}, {"role": "user", "content": report_prompt} ] response = llm.invoke(messages) report_content = response.content # === Add figure gallery at the end === figure_gallery = "\n\n---\n\n## Appendix: Figure Gallery\n\n" for fig in figures[:30]: # Limit to 30 figures fig_name = os.path.basename(fig) figure_gallery += f"### {fig_name}\n\n\n" full_report = report_content + figure_gallery # === Save report === with open(output_path, 'w') as f: f.write(full_report) # Also save as HTML for better viewing html_path = None try: import markdown html_content = f"""