| """Spatial Transcriptomics Agent using LangGraph.""" |
|
|
| from typing import Annotated, List, Dict, Any, TypedDict, Literal |
| import os, re, operator, warnings, uuid, logging, signal |
| warnings.filterwarnings('ignore') |
|
|
| from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage |
| from langgraph.graph import StateGraph, END, START |
| from langgraph.checkpoint.memory import MemorySaver |
|
|
| from .make_prompt import AgentPrompts |
| from .tool_system import ToolRegistry, EmbedToolRetriever, ToolExecutor, LLMToolSelector |
| from .skills import SkillManager |
|
|
|
|
|
|
| class AgentState(TypedDict): |
| """State of the agent graph.""" |
| messages: list[BaseMessage] |
| next_step: str | None |
|
|
|
|
| class SpatialAgent: |
| """SpatialAgent with simplified execution-focused architecture.""" |
|
|
| def __init__( |
| self, |
| llm=None, |
| tools: List = None, |
| data_path: str = "./data", |
| save_path: str = "./experiments", |
| tool_retrieval: bool = True, |
| tool_retrieval_method: str = "llm", |
| min_tools: int = 5, |
| max_tools: int = 20, |
| skill_retrieval: bool = True, |
| num_skills: int = 1, |
| auto_interpret_figures: bool = True, |
| act_timeout: int = 1800, |
| web_search_model: str = "gemini-3-flash-preview", |
| ): |
| """ |
| Initialize SpatialAgent with dynamic tool loading. |
| |
| Args: |
| llm: Language model instance. If None, defaults to Claude Sonnet 4.5. |
| tools: List of tool instances (LangChain tools). If None, auto-loads all tools. |
| data_path: Path to reference data directory (default: "./data"). |
| save_path: Directory where all results should be saved (default: "./experiments"). |
| tool_retrieval: Enable dynamic tool retrieval based on query (default: True). |
| tool_retrieval_method: Method for retrieving tools (default: "llm"): |
| - "llm": LLM-based selection (recommended, most accurate) |
| - "embedding": Embedding similarity search (faster, less accurate) |
| - "all": Use all tools (no retrieval) |
| min_tools: Minimum number of tools to retrieve (default: 5). |
| max_tools: Maximum number of tools to retrieve (default: 20). |
| skill_retrieval: Enable skill/workflow template retrieval (default: True). |
| num_skills: Maximum number of skills to retrieve per query (default: 1). |
| auto_interpret_figures: Automatically interpret generated figures using vision LLM (default: True). |
| act_timeout: Timeout in seconds for each <act> code execution (default: 1800 = 30 minutes). |
| web_search_model: Model to use for web_search tool (default: "gemini-3-flash-preview"). |
| If None, uses the agent's model. Set to a specific model like "gemini-3-flash-preview" |
| to always use that model for web search regardless of the agent's LLM. |
| """ |
| |
| if llm is None: |
| from .make_llm import make_llm, DEFAULT_CLAUDE_MODEL |
| print(f"No LLM provided, using default: {DEFAULT_CLAUDE_MODEL}", flush=True) |
| llm = make_llm(DEFAULT_CLAUDE_MODEL) |
| self.llm = llm |
| self.tool_retrieval = tool_retrieval |
| self.min_tools = min_tools |
| self.max_tools = max_tools |
| self.auto_interpret_figures = auto_interpret_figures |
| self.act_timeout = act_timeout |
|
|
| |
| from . import set_agent_model |
| |
| |
| |
| model_name = None |
| for attr in ['deployment_name', 'model_id', 'model_name', 'model']: |
| val = getattr(llm, attr, None) |
| if val and isinstance(val, str): |
| model_name = val |
| break |
| if not model_name: |
| model_name = "unknown" |
| set_agent_model(model_name, llm) |
|
|
| |
| |
| self.web_search_model = web_search_model if web_search_model else model_name |
|
|
| |
| self.observation_log = [] |
| self._observation_log_path = os.path.join(save_path, "observation_log.jsonl") |
|
|
| |
| data_path = os.path.abspath(data_path) |
| save_path = os.path.abspath(save_path) |
| self.save_path = save_path |
| self.data_path = data_path |
| print(f"Data path: {data_path}", flush=True) |
| print(f"Save path: {save_path}", flush=True) |
|
|
| |
| os.makedirs(save_path, exist_ok=True) |
| os.makedirs(data_path, exist_ok=True) |
|
|
| |
| self.tool_registry = ToolRegistry() |
| self.tool_executor = ToolExecutor(self.tool_registry) |
|
|
| |
| if tools is None: |
| print("Auto-loading tools from tool modules...", flush=True) |
| from .utils import load_all_tools |
| tools = load_all_tools(save_path=save_path, data_path=data_path) |
| print(f"Loaded {len(tools)} tools", flush=True) |
|
|
| |
| for tool in tools: |
| self.tool_registry.register_langchain_tool(tool) |
|
|
| |
| from spatialagent.tool.coding import inject_tools_into_repl |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def make_tool_wrapper(langchain_tool, ws_model: str = None): |
| """Create a wrapper that accepts both dict and keyword argument calling styles. |
| |
| LangChain's tool.invoke() expects a single dict argument, but LLMs often generate |
| code with keyword arguments. This wrapper handles both: |
| - tool({"arg1": val1, "arg2": val2}) # dict style |
| - tool(arg1=val1, arg2=val2) # keyword style |
| |
| For web_search, automatically injects the configured web_search_model. |
| """ |
| def wrapper(*args, **kwargs): |
| |
| if len(args) == 1 and isinstance(args[0], dict) and not kwargs: |
| input_dict = args[0].copy() |
| elif kwargs and not args: |
| input_dict = kwargs.copy() |
| elif args and not kwargs: |
| param_names = list(langchain_tool.args_schema.model_fields.keys()) if hasattr(langchain_tool, 'args_schema') else [] |
| if len(args) <= len(param_names): |
| input_dict = {param_names[i]: args[i] for i in range(len(args))} |
| else: |
| return langchain_tool.invoke(args[0]) |
| else: |
| param_names = list(langchain_tool.args_schema.model_fields.keys()) if hasattr(langchain_tool, 'args_schema') else [] |
| input_dict = {} |
| for i, arg in enumerate(args): |
| if i < len(param_names): |
| input_dict[param_names[i]] = arg |
| input_dict.update(kwargs) |
|
|
| |
| if langchain_tool.name == "web_search" and ws_model: |
| if "model" not in input_dict or input_dict.get("model") is None: |
| input_dict["model"] = ws_model |
|
|
| return langchain_tool.invoke(input_dict) |
|
|
| |
| wrapper.__name__ = langchain_tool.name |
| wrapper.__doc__ = langchain_tool.description |
| return wrapper |
|
|
| tool_functions = {} |
| for tool in tools: |
| |
| if tool.name not in ["execute_python", "execute_bash"]: |
| tool_functions[tool.name] = make_tool_wrapper(tool, ws_model=self.web_search_model) |
| inject_tools_into_repl(tool_functions) |
|
|
| |
| self.tool_retrieval_method = tool_retrieval_method |
| self.tool_selector = None |
| self.tool_retriever = None |
|
|
| if self.tool_retrieval: |
| if tool_retrieval_method == "llm": |
| print(f"Initializing LLM-based tool retrieval ({model_name})...", flush=True) |
| self.tool_selector = LLMToolSelector( |
| self.tool_registry, |
| min_tools=min_tools, |
| max_tools=max_tools |
| ) |
| elif tool_retrieval_method == "embedding": |
| print(f"Initializing embedding-based tool retrieval...", flush=True) |
| self.tool_retriever = EmbedToolRetriever( |
| self.tool_registry, |
| min_tools=min_tools, |
| max_tools=max_tools |
| ) |
| elif tool_retrieval_method == "all": |
| print(f"Using all {len(self.tool_registry.tools)} tools (no retrieval)...", flush=True) |
| else: |
| raise ValueError(f"Unknown tool_retrieval_method: {tool_retrieval_method}") |
|
|
| |
| self._active_tools = [] |
| self._last_human_msg_count = 0 |
| self._context_injected = False |
|
|
| |
| self.skill_retrieval = skill_retrieval |
| self.num_skills = num_skills |
| if self.skill_retrieval: |
| |
| skills_dir = os.path.join(os.path.dirname(__file__), '..', 'skill') |
| self.skill_manager = SkillManager(skills_dir) |
| self.skill_manager.set_llm(llm) |
| skills = self.skill_manager.load_skills() |
| if skills: |
| print(f"Loaded {len(skills)} skills: {', '.join(skills.keys())}", flush=True) |
| else: |
| self.skill_manager = None |
| self._selected_skill = None |
|
|
| |
| self.cost_callback = None |
| if hasattr(llm, 'callbacks'): |
| from .make_llm import CostCallback |
| for cb in llm.callbacks: |
| if isinstance(cb, CostCallback): |
| self.cost_callback = cb |
| break |
|
|
| |
| self.system_prompt = self._build_system_prompt() |
|
|
| |
| self.graph = self._build_graph() |
| self.app = self.graph.compile(checkpointer=MemorySaver()) |
|
|
| def _build_system_prompt(self) -> str: |
| """Build system prompt with dynamic tool loading capability.""" |
| |
| if self.tool_retrieval: |
| tool_info = f""" |
| # Tool Discovery |
| |
| You have access to {len(self.tool_registry.tools)} specialized tools via **dynamic retrieval**. |
| |
| **How it works:** |
| 1. When you need a tool, the system automatically searches for relevant tools based on your query |
| 2. Top {self.max_tools} most relevant tools are retrieved and made available |
| 3. Tools are executed by the agent when you use <act> tags with appropriate code |
| |
| **Important**: Tools are loaded dynamically - you don't need to know all tool names upfront. |
| The system will retrieve relevant tools based on your task. |
| """ |
| else: |
| |
| tool_descriptions = [ |
| "**Available tools:**\n" |
| ] |
| for tool_name in self.tool_registry.list_tools(): |
| tool = self.tool_registry.get_tool(tool_name) |
| tool_descriptions.append(f"- {tool_name}: {tool.description}") |
| tool_info = "\n".join(tool_descriptions) |
|
|
| |
| return AgentPrompts.SYSTEM_PROMPT(tool_info, save_path=self.save_path) |
|
|
|
|
| def _build_graph(self) -> StateGraph: |
| """Build the simplified LangGraph state machine.""" |
|
|
| workflow = StateGraph(AgentState) |
|
|
| |
| workflow.add_node("plan", self._plan_node) |
| workflow.add_node("act", self._act_node) |
|
|
| |
| workflow.add_edge(START, "plan") |
| workflow.add_conditional_edges( |
| "plan", |
| self._routing_function, |
| { |
| "act": "act", |
| "plan": "plan", |
| "end": END, |
| } |
| ) |
| workflow.add_edge("act", "plan") |
|
|
| return workflow |
|
|
| def _format_tool_info(self, tool) -> str: |
| """Format a tool's info including parameters for display to LLM.""" |
| lines = [f"**{tool.name}**: {tool.description}"] |
| if "properties" in tool.input_schema: |
| params = [] |
| required = tool.input_schema.get("required", []) |
| for param_name, param_info in tool.input_schema["properties"].items(): |
| param_type = param_info.get("type", "any") |
| param_desc = param_info.get("description", "") |
| req_marker = "*" if param_name in required else "" |
| params.append(f" - {param_name}{req_marker} ({param_type}): {param_desc}") |
| if params: |
| lines.append(" Parameters:") |
| lines.extend(params) |
| return "\n".join(lines) |
|
|
| def _plan_node(self, state: AgentState) -> Dict[str, Any]: |
| """Plan node: LLM thinks and decides what to do next.""" |
| |
| human_msg_count = sum(1 for m in state["messages"] if isinstance(m, HumanMessage)) |
|
|
| |
| if state["messages"] and human_msg_count > self._last_human_msg_count: |
| self._last_human_msg_count = human_msg_count |
|
|
| |
| user_query = "" |
| for msg in reversed(state["messages"]): |
| if isinstance(msg, HumanMessage): |
| user_query = msg.content |
| break |
|
|
| if user_query: |
| |
| skill_context = "" |
| skill_tools = [] |
| if self.skill_retrieval and self.skill_manager: |
| skill_contents = self.skill_manager.select_skill(user_query, num_skills=self.num_skills) |
| if skill_contents: |
| |
| skill_names = [] |
| for skill_content in skill_contents: |
| |
| for name, content in self.skill_manager.skills.items(): |
| if content == skill_content: |
| skill_names.append(name) |
| break |
| |
| skill_context += self.skill_manager.format_skill_guidance(skill_content) |
| |
| tools = self.skill_manager.extract_tools_from_skill(skill_content) |
| skill_tools.extend([t for t in tools if t in self.tool_registry.tools]) |
|
|
| |
| skill_tools = list(dict.fromkeys(skill_tools)) |
| self._selected_skill = skill_contents |
|
|
| print(f"\033[1m<skill>\033[0m retrieved {', '.join(skill_names)} \033[1m</skill>\033[0m\n", flush=True) |
| if skill_tools: |
| print(f"\033[1m<skill-tools>\033[0m {'; '.join(skill_tools)} \033[1m</skill-tools>\033[0m\n", flush=True) |
| else: |
| print(f"\033[1m<skill>\033[0m no workflow needed \033[1m</skill>\033[0m\n", flush=True) |
| self._selected_skill = [] |
|
|
| |
| tool_context = "" |
| if self.tool_retrieval: |
| if self.tool_retrieval_method == "llm" and self.tool_selector: |
| |
| self._active_tools = self.tool_selector.select(user_query, skill_tools=skill_tools) |
| elif self.tool_retrieval_method == "embedding" and self.tool_retriever: |
| |
| self._active_tools = self.tool_retriever.select(user_query, skill_tools=skill_tools) |
| elif self.tool_retrieval_method == "all": |
| |
| self._active_tools = list(self.tool_registry.tools.keys()) |
| else: |
| |
| self._active_tools = skill_tools |
|
|
| |
| if self._active_tools: |
| tool_names = "; ".join(self._active_tools) |
| print(f"\033[1m<tool>\033[0m selected {tool_names} \033[1m</tool>\033[0m\n", flush=True) |
|
|
| |
| tool_details = [] |
| for name in self._active_tools: |
| tool = self.tool_registry.get_tool(name) |
| if tool: |
| tool_details.append(self._format_tool_info(tool)) |
| tool_context = "# Selected Tools (call with dict argument)\n" + "\n\n".join(tool_details) |
|
|
| |
| |
| if not self._context_injected and (tool_context or skill_context): |
| context_parts = [] |
| if tool_context: |
| context_parts.append(tool_context) |
| if skill_context: |
| context_parts.append(skill_context) |
| context_msg = "[System Context]\n" + "\n\n".join(context_parts) |
| state["messages"].append(HumanMessage(content=context_msg)) |
| self._context_injected = True |
| |
| |
| self._last_human_msg_count = sum(1 for m in state["messages"] if isinstance(m, HumanMessage)) |
|
|
| |
| messages = [SystemMessage(content=self.system_prompt)] + state["messages"] |
|
|
| |
| try: |
| logging.debug(f"Invoking LLM with {len(messages)} messages...") |
| response = self.llm.invoke(messages) |
| msg = str(response.content) |
| logging.debug(f"LLM response received, length: {len(msg)}") |
| except Exception as e: |
| logging.error(f"LLM invocation failed: {type(e).__name__}: {e}", exc_info=True) |
| raise |
|
|
| |
| if "<observation>" in msg: |
| logging.warning("LLM generated hallucinated <observation> tags - stripping them out") |
| msg = re.sub(r"<observation>.*?</observation>", "", msg, flags=re.DOTALL) |
|
|
| |
| if "<think>" in msg and "</think>" not in msg: |
| msg += "</think>" |
| if "<act>" in msg and "</act>" not in msg: |
| msg += "</act>" |
| if "<conclude>" in msg and "</conclude>" not in msg: |
| msg += "</conclude>" |
|
|
| |
| think_match = re.search(r"<think>(.*?)</think>", msg, re.DOTALL) |
| act_match = re.search(r"<act>(.*?)</act>", msg, re.DOTALL) |
| conclude_match = re.search(r"<conclude>(.*?)</conclude>", msg, re.DOTALL) |
|
|
| |
| state["messages"].append(AIMessage(content=msg.strip())) |
|
|
| |
| if conclude_match: |
| state["next_step"] = "end" |
| elif act_match: |
| state["next_step"] = "act" |
| elif think_match: |
| state["next_step"] = "plan" |
| else: |
| |
| |
| |
| print("parsing error...", flush=True) |
| |
| error_count = sum( |
| 1 for m in state["messages"] |
| if isinstance(m, AIMessage) and "There are no tags" in m.content |
| ) |
|
|
| if error_count >= 2: |
| |
| print("Detected repeated parsing errors, ending conversation", flush=True) |
| state["next_step"] = "end" |
| state["messages"].append( |
| AIMessage( |
| content="Execution terminated due to repeated parsing errors. Please check your input and try again." |
| ) |
| ) |
| else: |
| |
| state["messages"].append( |
| HumanMessage( |
| content="Each response must include either <act> or <conclude> tag. But there are no tags in the current response. Please include <act> with code to execute, or <conclude> with your final answer." |
| ) |
| ) |
| state["next_step"] = "plan" |
|
|
| return state |
|
|
| def _log_observation(self, step_number: int, code: str, result: str, figure_interpretations: str = ""): |
| """Log an observation for later report generation. |
| |
| Args: |
| step_number: The current step number in the analysis |
| code: The code that was executed |
| result: The execution result/output |
| figure_interpretations: Any figure interpretations generated |
| """ |
| import json |
| from datetime import datetime |
|
|
| |
| entry = { |
| "step": step_number, |
| "timestamp": datetime.now().isoformat(), |
| |
| |
| |
| "code_snippet": code, |
| "result_summary": result, |
| "figure_interpretations": figure_interpretations, |
| } |
|
|
| |
| self.observation_log.append(entry) |
|
|
| |
| try: |
| with open(self._observation_log_path, 'a') as f: |
| f.write(json.dumps(entry) + '\n') |
| except Exception as e: |
| print(f"Warning: Could not write to observation log: {e}") |
|
|
| def _display_figures(self, code_context: str = "", user_query: str = "") -> str: |
| """Display any new image files and optionally interpret them using vision LLM. |
| |
| Args: |
| code_context: The code that generated the figures (used as context for interpretation) |
| user_query: The user's original query for biological context |
| |
| Returns: |
| String containing figure interpretations (empty string if no figures or interpretation disabled) |
| """ |
| interpretations = [] |
|
|
| try: |
| from spatialagent.tool.coding import get_new_image_files |
| image_files = get_new_image_files() |
| if not image_files: |
| return "" |
|
|
| |
| try: |
| from IPython.display import display, Image, SVG |
| import os |
| print(f"📊 Displaying {len(image_files)} figure(s)...") |
| for img_path in image_files: |
| if not os.path.exists(img_path): |
| print(f"⚠️ File not found: {img_path}") |
| continue |
| ext = os.path.splitext(img_path)[1].lower() |
| if ext == '.svg': |
| display(SVG(filename=img_path)) |
| elif ext in ('.png', '.jpg', '.jpeg'): |
| display(Image(filename=img_path)) |
| elif ext == '.pdf': |
| |
| print(f"📄 Created: {os.path.basename(img_path)}") |
| except ImportError: |
| |
| import os |
| print(f"[{len(image_files)} figure(s) created: {', '.join(os.path.basename(f) for f in image_files)}]") |
|
|
| |
| if self.auto_interpret_figures and image_files: |
| print(f"🔍 Interpreting {len(image_files)} figure(s)...") |
|
|
| from spatialagent.tool.interpretation import interpret_figure |
|
|
| for img_path in image_files: |
| import os |
| if not os.path.exists(img_path): |
| continue |
|
|
| ext = os.path.splitext(img_path)[1].lower() |
| |
| if ext == '.pdf': |
| continue |
|
|
| try: |
| |
| |
| context = self._infer_figure_context(code_context, img_path, user_query) |
|
|
| |
| interpretation = interpret_figure.invoke({ |
| "image_path": img_path, |
| "context": context, |
| "analysis_focus": "general" |
| }) |
|
|
| fig_name = os.path.basename(img_path) |
| interpretations.append(f"\n### Figure Interpretation: {fig_name}\n{interpretation}") |
|
|
| except Exception as e: |
| print(f"⚠️ Could not interpret {os.path.basename(img_path)}: {e}") |
|
|
| except Exception as e: |
| |
| print(f"⚠️ Error displaying/interpreting figures: {e}") |
|
|
| return "\n".join(interpretations) if interpretations else "" |
|
|
| def _infer_figure_context(self, code: str, img_path: str, user_query: str = "") -> str: |
| """Infer the context/type of a figure from the code that generated it. |
| |
| Args: |
| code: The Python code that generated the figure |
| img_path: Path to the generated image |
| user_query: The user's original query for biological context |
| """ |
| import os |
|
|
| |
| fig_name = os.path.basename(img_path) |
| context_parts = [f"Figure: {fig_name}"] |
|
|
| code_lower = code.lower() |
|
|
| |
| plot_types = [] |
| if "umap" in code_lower: |
| plot_types.append("UMAP dimensionality reduction") |
| if "tsne" in code_lower or "t-sne" in code_lower: |
| plot_types.append("t-SNE dimensionality reduction") |
| if "pca" in code_lower and "plot" in code_lower: |
| plot_types.append("PCA plot") |
| if "sc.pl.spatial" in code_lower or "sq.pl.spatial" in code_lower or "spatial_scatter" in code_lower: |
| plot_types.append("Spatial plot showing tissue coordinates") |
| if "heatmap" in code_lower or "sns.heatmap" in code_lower or "clustermap" in code_lower: |
| plot_types.append("Heatmap visualization") |
| if "violin" in code_lower: |
| plot_types.append("Violin plot") |
| if "dotplot" in code_lower or "dot_plot" in code_lower: |
| plot_types.append("Dot plot") |
| if "stacked_violin" in code_lower: |
| plot_types.append("Stacked violin plot") |
| if "matrixplot" in code_lower: |
| plot_types.append("Matrix plot") |
| if "rank_genes" in code_lower: |
| plot_types.append("Ranked genes plot") |
| if "barplot" in code_lower or "bar(" in code_lower or "barh(" in code_lower: |
| plot_types.append("Bar plot") |
| if "boxplot" in code_lower: |
| plot_types.append("Box plot") |
| if "scatter" in code_lower and "spatial" not in code_lower: |
| plot_types.append("Scatter plot") |
|
|
| if plot_types: |
| context_parts.append(" + ".join(plot_types)) |
|
|
| |
| color_by = [] |
| if "cell_type" in code_lower or "celltype" in code_lower or "tier3" in code_lower: |
| color_by.append("cell type") |
| if "leiden" in code_lower: |
| color_by.append("Leiden clusters") |
| if "louvain" in code_lower: |
| color_by.append("Leiden clusters") |
| if "batch" in code_lower or "sample" in code_lower: |
| color_by.append("batch/sample") |
| if "condition" in code_lower or "sample_type" in code_lower: |
| color_by.append("condition/disease stage") |
| if "leiden_neigh" in code_lower or "neighborhood" in code_lower or "neigh" in code_lower: |
| color_by.append("spatial neighborhood") |
| if "niche" in code_lower: |
| color_by.append("tissue niche") |
|
|
| if color_by: |
| context_parts.append(f"colored/grouped by: {', '.join(color_by)}") |
|
|
| |
| title_patterns = [ |
| r'plt\.title\s*\(\s*[\'"]([^\'"]+)[\'"]', |
| r'\.set_title\s*\(\s*[\'"]([^\'"]+)[\'"]', |
| r'title\s*=\s*[\'"]([^\'"]+)[\'"]', |
| ] |
| for pattern in title_patterns: |
| match = re.search(pattern, code) |
| if match: |
| context_parts.append(f"Title: {match.group(1)}") |
| break |
|
|
| |
| gene_patterns = [ |
| r'var_names\s*=\s*\[([^\]]+)\]', |
| r'genes\s*=\s*\[([^\]]+)\]', |
| r"color\s*=\s*['\"]([A-Z][A-Z0-9]+)['\"]", |
| ] |
| for pattern in gene_patterns: |
| match = re.search(pattern, code, re.IGNORECASE) |
| if match: |
| genes = match.group(1).strip() |
| if len(genes) < 200: |
| context_parts.append(f"Genes: {genes}") |
| break |
|
|
| |
| comment_pattern = r'#\s*(.+?)$' |
| comments = re.findall(comment_pattern, code, re.MULTILINE) |
| relevant_comments = [c.strip() for c in comments if len(c.strip()) > 10 and len(c.strip()) < 100] |
| if relevant_comments: |
| |
| context_parts.append(f"Code comments: {'; '.join(relevant_comments[:2])}") |
|
|
| |
| if "comparison" in code_lower or "vs" in code_lower or "versus" in code_lower: |
| context_parts.append("Comparative analysis") |
| if "composition" in code_lower: |
| context_parts.append("Composition analysis") |
| if "proportion" in code_lower or "percentage" in code_lower: |
| context_parts.append("Proportion/percentage analysis") |
| if "dynamics" in code_lower or "trajectory" in code_lower: |
| context_parts.append("Dynamics/trajectory analysis") |
| if "interaction" in code_lower: |
| context_parts.append("Cell-cell interaction analysis") |
|
|
| |
| if user_query: |
| |
| query_truncated = user_query[:500] if len(user_query) > 500 else user_query |
| context_parts.append(f"Biological context: {query_truncated}") |
|
|
| return " | ".join(context_parts) |
|
|
| def _act_node(self, state: AgentState) -> Dict[str, Any]: |
| """Act node: runs code from <act> tags using tool-based execution with timeout.""" |
| last_message = state["messages"][-1].content |
| act_match = re.search(r"<act>(.*?)</act>", last_message, re.DOTALL) |
|
|
| if not act_match: |
| state["messages"].append(AIMessage(content="No action to execute")) |
| return state |
|
|
| code = act_match.group(1).strip() |
| figure_interpretations = "" |
|
|
| |
| user_query = "" |
| for msg in reversed(state["messages"]): |
| if isinstance(msg, HumanMessage): |
| content = msg.content |
| |
| if not content.startswith("[System Context]"): |
| user_query = content |
| break |
|
|
| |
| def timeout_handler(signum, frame): |
| raise TimeoutError(f"Code execution timed out after {self.act_timeout} seconds") |
|
|
| |
| result = "" |
| try: |
| |
| old_handler = signal.signal(signal.SIGALRM, timeout_handler) |
| signal.alarm(self.act_timeout) |
|
|
| try: |
| |
| if code.startswith("#!BASH") or code.startswith("# Bash"): |
| |
| bash_code = re.sub(r"^#!BASH|^# Bash script|^# Bash", "", code, 1).strip() |
| result = self.tool_executor.execute_tool("execute_bash", command=bash_code) |
| else: |
| |
| result = self.tool_executor.execute_tool("execute_python", code=code) |
| |
| figure_interpretations = self._display_figures(code_context=code, user_query=user_query) |
| finally: |
| |
| signal.alarm(0) |
| signal.signal(signal.SIGALRM, old_handler) |
|
|
| except TimeoutError as e: |
| result = f"ERROR: {str(e)}\n\nThe code execution was terminated. Consider:\n- Breaking the task into smaller steps\n- Using more efficient algorithms\n- Processing data in chunks" |
|
|
| |
| if len(result) > 15000: |
| result = result[:15000] + "\n... (output truncated)" |
|
|
| |
| if figure_interpretations: |
| observation = f"<observation>\n{result}\n\n## Auto-Generated Figure Analysis\n{figure_interpretations}\n</observation>" |
| else: |
| observation = f"<observation>\n{result}\n</observation>" |
|
|
| |
| step_number = len([m for m in state["messages"] if isinstance(m, AIMessage) and "<act>" in str(m.content)]) |
| self._log_observation( |
| step_number=step_number, |
| code=code, |
| result=result, |
| figure_interpretations=figure_interpretations |
| ) |
|
|
| state["messages"].append(AIMessage(content=observation)) |
| logging.debug(f"_act_node completed, returning state with {len(state['messages'])} messages") |
| return state |
|
|
| def _routing_function(self, state: AgentState) -> str: |
| """Route to next node based on state.""" |
| return state.get("next_step", "plan") |
|
|
| def _print_message(self, message: BaseMessage): |
| """Print a message with appropriate formatting based on its content. |
| |
| Tags are displayed with content on separate lines: |
| <tag> |
| content |
| </tag> |
| """ |
| import sys |
|
|
| |
| if isinstance(message, HumanMessage): |
| return |
|
|
| msg = message.content |
| if not msg or not isinstance(msg, str): |
| return |
|
|
| |
| msg_stripped = msg.strip() |
| if not msg_stripped or msg_stripped == "[]" or msg_stripped == "{}": |
| return |
|
|
| |
| if msg_stripped.startswith("[System]"): |
| return |
|
|
| def format_tag(text, tag, color_code): |
| """Replace <tag>content</tag> with formatted multi-line version.""" |
| pattern = rf"<{tag}>(.*?)</{tag}>" |
|
|
| def replacer(match): |
| content = match.group(1).strip() |
| return f"{color_code}<{tag}>\033[0m\n{content}\n{color_code}</{tag}>\033[0m" |
|
|
| return re.sub(pattern, replacer, text, flags=re.DOTALL) |
|
|
| |
| conclude_match = re.search(r"<conclude>(.*?)</conclude>", msg, re.DOTALL) |
|
|
| if conclude_match: |
| try: |
| from rich.console import Console |
| from rich.markdown import Markdown |
| from rich.theme import Theme |
|
|
| custom_theme = Theme({ |
| "markdown.code": "bold cyan", |
| "markdown.code_block": "cyan on grey93", |
| }) |
| console = Console(theme=custom_theme, force_terminal=True) |
|
|
| |
| conclude_content = conclude_match.group(1).strip() |
|
|
| |
| if conclude_match.start() > 0: |
| pre_conclude = msg[:conclude_match.start()].strip() |
| pre_conclude = format_tag(pre_conclude, "act", "\033[91m") |
| pre_conclude = format_tag(pre_conclude, "observation", "\033[94m") |
| print(pre_conclude) |
| print() |
| sys.stdout.flush() |
|
|
| |
| print("\033[1m<conclude>\033[0m") |
| md = Markdown(conclude_content, code_theme="github-light", inline_code_theme="cyan") |
| console.print(md) |
| print("\033[1m</conclude>\033[0m") |
| print() |
| sys.stdout.flush() |
|
|
| except ImportError: |
| |
| display_msg = format_tag(msg_stripped, "act", "\033[91m") |
| display_msg = format_tag(display_msg, "observation", "\033[94m") |
| display_msg = format_tag(display_msg, "conclude", "\033[1m") |
| print(display_msg) |
| print() |
| sys.stdout.flush() |
| else: |
| |
| display_msg = format_tag(msg_stripped, "act", "\033[91m") |
| display_msg = format_tag(display_msg, "observation", "\033[94m") |
| print(display_msg) |
| print() |
| sys.stdout.flush() |
|
|
| def run(self, user_query: str, config: Dict[str, Any] = None) -> Dict[str, Any]: |
| """ |
| Run the agent with a user query. |
| |
| Args: |
| user_query: The user's task/question |
| config: Optional LangGraph configuration dict. |
| If not provided, defaults to {"recursion_limit": 50} |
| |
| Returns: |
| Final agent state |
| """ |
| |
| self._last_human_msg_count = 0 |
| self._selected_skill = None |
| self._context_injected = False |
|
|
| |
| |
| |
| |
| if config is None: |
| config = {"recursion_limit": 50} |
| elif "recursion_limit" not in config: |
| config["recursion_limit"] = 50 |
|
|
| |
| |
| langgraph_config = { |
| "recursion_limit": config.get("recursion_limit", 50), |
| "configurable": { |
| "thread_id": config.get("thread_id", str(uuid.uuid4())) |
| } |
| } |
|
|
| |
| import sys |
| print(f"\033[1m<user query>\033[0m\n{user_query.strip()}\n\033[1m</user query>\033[0m\n") |
| sys.stdout.flush() |
|
|
| |
| thread_id = langgraph_config["configurable"]["thread_id"] |
| existing_messages = [] |
| try: |
| |
| existing_state = self.app.get_state(langgraph_config) |
| if existing_state and existing_state.values and "messages" in existing_state.values: |
| existing_messages = existing_state.values["messages"] |
| except Exception: |
| |
| pass |
|
|
| |
| if existing_messages: |
| initial_state = { |
| "messages": existing_messages + [HumanMessage(content=user_query)], |
| "next_step": None, |
| } |
| prev_message_count = len(existing_messages) + 1 |
| else: |
| initial_state = { |
| "messages": [HumanMessage(content=user_query)], |
| "next_step": None, |
| } |
| prev_message_count = 1 |
|
|
| |
| try: |
| final_state = None |
| conclude_reached = False |
|
|
| |
| logging.debug("Starting graph stream...") |
| step_count = 0 |
| for state_update in self.app.stream(initial_state, stream_mode="values", config=langgraph_config): |
| step_count += 1 |
| messages = state_update.get("messages", []) |
| next_step = state_update.get("next_step") |
| logging.debug(f"Stream step {step_count}: {len(messages)} messages, next_step={next_step}") |
|
|
| |
| for i in range(prev_message_count, len(messages)): |
| if not conclude_reached: |
| self._print_message(messages[i]) |
| |
| msg_content = messages[i].content if hasattr(messages[i], 'content') else "" |
| if isinstance(msg_content, str) and "<conclude>" in msg_content: |
| conclude_reached = True |
|
|
| prev_message_count = len(messages) |
| final_state = state_update |
|
|
| |
| if next_step == "end" or conclude_reached: |
| logging.debug(f"Breaking stream loop: next_step={next_step}, conclude_reached={conclude_reached}") |
| break |
|
|
| logging.debug(f"Stream loop ended after {step_count} steps") |
|
|
| |
| if self.cost_callback: |
| self.cost_callback.print_summary() |
|
|
| return final_state |
|
|
| except Exception as e: |
| print(f"Error: {e}", flush=True) |
| raise |
|
|