| """ |
| CodeAgent: A LangGraph-based agent for executing Python code and using tools. |
| Fully modular version with unified tool management. |
| """ |
|
|
| import os |
| import re |
| import time |
|
|
| from dotenv import load_dotenv |
| from jinja2 import Template |
| from langchain_core.language_models.chat_models import BaseChatModel |
| from langchain_core.messages import AIMessage, HumanMessage, SystemMessage |
|
|
| from core.access_control import principal_trace_fields |
| from core.constants import SYSTEM_PROMPT_TEMPLATE |
| from core.run_metrics import compute_run_metrics |
|
|
| |
| from core.types import AgentConfig, AgentState |
|
|
| |
| from managers import ( |
| ConsoleDisplay, |
| Executor, |
| |
| PackageManager, |
| |
| PlanManager, |
| |
| Timing, |
| |
| ToolManager, |
| ToolSource, |
| WorkflowEngine, |
| create_agent_state, |
| get_executor, |
| ) |
|
|
| |
| load_dotenv("./.env") |
|
|
|
|
| def get_system_prompt( |
| functions: dict[str, dict], |
| packages: dict[str, str] = None, |
| datasets: dict[str, dict] = None, |
| ) -> str: |
| """Generate system prompt using template and functions.""" |
| if packages is None: |
| from core.constants import LIBRARY_CONTENT_DICT |
|
|
| packages = LIBRARY_CONTENT_DICT |
| if datasets is None: |
| from src.datasets.registry import get_registry, is_advertised |
|
|
| _reg = get_registry() |
| datasets = {} |
| for _did in _reg.list(): |
| _raw = _reg.get(_did) or {} |
| _expr = _raw.get("expression_source", {}) |
| _feat = _raw.get("feature_mapping", {}) |
| datasets[_did] = { |
| "dataset_id": _did, |
| "title": _raw.get("title", ""), |
| "preprocessing": _raw.get("preprocessing", ""), |
| "accession": _raw.get("accession", ""), |
| "organism": _raw.get("organism", ""), |
| "modality": _raw.get("modality", ""), |
| "advertised": is_advertised(_raw.get("modality", "")), |
| "data_level": _raw.get("data_level", ""), |
| "expression_url": _expr.get("url", ""), |
| "feature_id_type": _raw.get("feature_id_type", ""), |
| "requires_collapse": _feat.get("requires_collapse", False), |
| "group_columns": _raw.get("group_columns", []), |
| "default_contrasts": _raw.get("default_contrasts", []), |
| "survival_columns": _raw.get("survival_columns") or {}, |
| "refusal_rules": _raw.get("refusal_rules", []), |
| "limitations": _raw.get("limitations", []), |
| "reporting_rules": _raw.get("reporting_rules", []), |
| "dataset_disclaimer": _raw.get("dataset_disclaimer") or "", |
| "has_curated_subset": bool(_raw.get("curated_sample_list")), |
| "curated_n": len(_raw.get("curated_sample_list") or []), |
| "curated_sample_source": _raw.get("curated_sample_source") or "", |
| } |
| return Template(SYSTEM_PROMPT_TEMPLATE).render( |
| functions=functions, packages=packages, datasets=datasets |
| ) |
|
|
|
|
| class CodeAgent: |
| """A code-based agent that can execute Python code and use tools to solve tasks.""" |
|
|
| def __init__(self, model: BaseChatModel, config: AgentConfig | None = None): |
| """ |
| Initialize the CodeAgent. |
| |
| Args: |
| model: The language model to use for generation |
| config: Configuration for the agent |
| """ |
| self.model = model |
| self.config = config or AgentConfig() |
|
|
| |
| |
| |
| |
| self.principal: str | None = None |
|
|
| |
| self.package_manager = PackageManager() |
| self.console = ConsoleDisplay() |
| self.tool_manager = ToolManager(self.console) |
| self.workflow_engine = WorkflowEngine(model, self.config, self.console) |
|
|
| |
| |
| |
| |
| |
| self.python_executor: Executor = get_executor() |
|
|
| |
| self._setup_workflow() |
|
|
| |
| |
| |
|
|
| def _setup_workflow(self): |
| """Setup the LangGraph workflow using WorkflowEngine.""" |
| self.workflow_engine.setup_workflow(self.generate, self.execute, self.should_continue) |
|
|
| |
| |
| |
|
|
| def generate(self, state: AgentState) -> AgentState: |
| """Generate response using LLM with tool-aware prompt.""" |
|
|
| all_schemas = self.tool_manager.get_tool_schemas(openai_format=True) |
| functions_dict = {schema["function"]["name"]: schema for schema in all_schemas} |
|
|
| all_packages = self.package_manager.get_all_packages() |
|
|
| from src.datasets.registry import get_registry, is_advertised |
|
|
| _reg = get_registry() |
| datasets = {} |
| for _did in _reg.list(): |
| _raw = _reg.get(_did) or {} |
| _expr = _raw.get("expression_source", {}) |
| _feat = _raw.get("feature_mapping", {}) |
| datasets[_did] = { |
| "dataset_id": _did, |
| "title": _raw.get("title", ""), |
| "preprocessing": _raw.get("preprocessing", ""), |
| "accession": _raw.get("accession", ""), |
| "organism": _raw.get("organism", ""), |
| "modality": _raw.get("modality", ""), |
| "advertised": is_advertised(_raw.get("modality", "")), |
| "data_level": _raw.get("data_level", ""), |
| "expression_url": _expr.get("url", ""), |
| "feature_id_type": _raw.get("feature_id_type", ""), |
| "requires_collapse": _feat.get("requires_collapse", False), |
| "group_columns": _raw.get("group_columns", []), |
| "default_contrasts": _raw.get("default_contrasts", []), |
| "survival_columns": _raw.get("survival_columns") or {}, |
| "refusal_rules": _raw.get("refusal_rules", []), |
| "limitations": _raw.get("limitations", []), |
| "reporting_rules": _raw.get("reporting_rules", []), |
| "dataset_disclaimer": _raw.get("dataset_disclaimer") or "", |
| "has_curated_subset": bool(_raw.get("curated_sample_list")), |
| "curated_n": len(_raw.get("curated_sample_list") or []), |
| "curated_sample_source": _raw.get("curated_sample_source") or "", |
| } |
|
|
| system_prompt = get_system_prompt(functions_dict, all_packages, datasets) |
|
|
| |
| |
| |
| |
| |
| all_messages = state["messages"] |
| original_task = all_messages[:1] |
| recent = all_messages[1:][-self.config.memory_window :] |
| |
| |
| |
| |
| |
| |
| if recent and isinstance(recent[0], HumanMessage): |
| recent = recent[1:] |
|
|
| history = original_task + recent |
|
|
| |
| |
| |
| |
| system_message = SystemMessage( |
| content=[ |
| { |
| "type": "text", |
| "text": system_prompt, |
| "cache_control": {"type": "ephemeral"}, |
| } |
| ] |
| ) |
| messages = [system_message] + history |
|
|
| |
| |
| |
| |
| if messages and isinstance(messages[-1], AIMessage): |
| messages = messages[:-1] + [HumanMessage(content=messages[-1].content)] |
|
|
| from core.perf import timed |
|
|
| step_no = state.get("step_count", 0) + 1 |
| with timed("generate", f"step{step_no}"): |
| response = self.model.invoke(messages) |
|
|
| |
| if "</execute>" in response.content: |
| response.content = response.content.split("</execute>")[0] + "</execute>" |
|
|
| |
| msg = str(response.content) |
| llm_reply = AIMessage(content=msg.strip()) |
|
|
| |
| new_step_count = state.get("step_count", 0) + 1 |
|
|
| return create_agent_state( |
| messages=[llm_reply], |
| step_count=new_step_count, |
| error_count=state.get("error_count", 0), |
| start_time=state.get("start_time", time.time()), |
| current_plan=self._extract_current_plan(msg), |
| ) |
|
|
| def _extract_current_plan(self, content: str) -> str | None: |
| """Extract the current plan from the agent's response.""" |
| return PlanManager.extract_plan_from_content(content) |
|
|
| def execute(self, state: AgentState) -> AgentState: |
| """Execute code using persistent Python executor.""" |
| try: |
| last_message = state["messages"][-1].content |
| execute_match = re.search(r"<execute>(.*?)</execute>", last_message, re.DOTALL) |
|
|
| if execute_match: |
| code = execute_match.group(1).strip() |
|
|
| |
| |
| |
| |
| |
| |
| |
| self.python_executor.send_functions(self.get_all_tool_functions()) |
|
|
| |
| from core.perf import timed |
|
|
| with timed("execute", f"step{state.get('step_count', 0)}"): |
| result = self.python_executor(code) |
|
|
| |
| obs = f"\n<observation>\nCode Output:\n{result}</observation>" |
| |
| |
| return create_agent_state( |
| messages=[HumanMessage(content=obs.strip())], |
| step_count=state.get("step_count", 0), |
| error_count=state.get("error_count", 0), |
| start_time=state.get("start_time", time.time()), |
| current_plan=state.get("current_plan"), |
| ) |
| else: |
| return create_agent_state( |
| messages=[HumanMessage(content="<error>No executable code found</error>")], |
| step_count=state.get("step_count", 0), |
| error_count=state.get("error_count", 0) + 1, |
| start_time=state.get("start_time", time.time()), |
| current_plan=state.get("current_plan"), |
| ) |
| except Exception as e: |
| return create_agent_state( |
| messages=[HumanMessage(content=f"<error>Execution error: {str(e)}</error>")], |
| step_count=state.get("step_count", 0), |
| error_count=state.get("error_count", 0) + 1, |
| start_time=state.get("start_time", time.time()), |
| current_plan=state.get("current_plan"), |
| ) |
|
|
| def _record_end_reason(self, reason: str) -> None: |
| """Stash why the run ended where the UI reads run outcomes.""" |
| if self.workflow_engine is not None: |
| self.workflow_engine.last_end_reason = reason |
|
|
| def should_continue(self, state: AgentState) -> str: |
| """Decide whether to continue executing or end the workflow. |
| |
| Records *why* the run ended in ``last_end_reason`` so the UI can tell the |
| three exhaustion cases apart. Continuing only helps the step case: a run |
| that ran out of wall clock gets nothing from 15 more steps, and telling |
| the user otherwise sends them round the same wall again. |
| """ |
| last_message = state["messages"][-1].content |
| step_count = state.get("step_count", 0) |
| error_count = state.get("error_count", 0) |
| start_time = state.get("start_time", time.time()) |
|
|
| |
| if time.time() - start_time > self.config.timeout_seconds: |
| self._record_end_reason("timeout") |
| return "end" |
|
|
| |
| if step_count >= self.config.max_steps: |
| self._record_end_reason("step_limit") |
| return "end" |
|
|
| |
| if error_count >= self.config.retry_attempts: |
| self._record_end_reason("error_limit") |
| return "end" |
|
|
| |
| if "<solution>" in last_message and "</solution>" in last_message: |
| self._record_end_reason("solution") |
| return "end" |
|
|
| |
| elif "<execute>" in last_message and "</execute>" in last_message: |
| return "execute" |
|
|
| else: |
| |
| self._record_end_reason("stalled") |
| return "end" |
|
|
| |
| |
| |
|
|
| def add_packages(self, packages: dict[str, str]) -> bool: |
| """Add new packages to the available packages.""" |
| return self.package_manager.add_packages(packages) |
|
|
| def get_all_packages(self) -> dict[str, str]: |
| """Get all available packages (default + custom).""" |
| return self.package_manager.get_all_packages() |
|
|
| |
| |
| |
|
|
| def add_tool(self, function: callable, name: str = None, description: str = None) -> bool: |
| """Add a tool function to the manager.""" |
| return self.tool_manager.add_tool(function, name, description, ToolSource.LOCAL) |
|
|
| def remove_tool(self, name: str) -> bool: |
| """Remove a tool by name.""" |
| return self.tool_manager.remove_tool(name) |
|
|
| def list_tools(self, source: str = "all", include_details: bool = False) -> list[dict]: |
| """List all available tools with optional filtering.""" |
| source_enum = ToolSource.ALL |
| if source.lower() in ["local", "decorated", "mcp"]: |
| source_enum = ToolSource(source.lower()) |
|
|
| return self.tool_manager.list_tools(source_enum, include_details) |
|
|
| def search_tools(self, query: str) -> list[dict]: |
| """Search tools by name and description.""" |
| return self.tool_manager.search_tools(query) |
|
|
| def get_tool_info(self, name: str) -> dict | None: |
| """Get detailed information about a specific tool.""" |
| tool_info = self.tool_manager.get_tool(name) |
| if tool_info: |
| return { |
| "name": tool_info.name, |
| "description": tool_info.description, |
| "source": tool_info.source.value, |
| "server": tool_info.server, |
| "module": tool_info.module, |
| "has_function": tool_info.function is not None, |
| "required_parameters": tool_info.required_parameters, |
| "optional_parameters": tool_info.optional_parameters, |
| } |
| return None |
|
|
| def get_all_tool_functions(self) -> dict[str, callable]: |
| """Get all tool functions as a dictionary.""" |
| return self.tool_manager.get_all_functions() |
|
|
| def _prepare_executor(self) -> None: |
| """Inject tools and packages into the Python executor before any run.""" |
| self.python_executor.send_functions(self.get_all_tool_functions()) |
| self.package_manager.import_packages(self.python_executor) |
|
|
| |
| |
| |
|
|
| def add_mcp(self, config_path: str = "./mcp_config.yaml") -> None: |
| """Add MCP tools from configuration file.""" |
| self.tool_manager.add_mcp_server(config_path) |
|
|
| def add_mcp_http(self, url: str, server_name: str = "decouplerpy") -> None: |
| """Add MCP tools from a persistent HTTP MCP server (no per-call spawn).""" |
| self.tool_manager.add_mcp_http_server(url, server_name=server_name) |
|
|
| def list_mcp_tools(self) -> list[dict]: |
| """List all loaded MCP tools.""" |
| return self.tool_manager.list_tools(ToolSource.MCP) |
|
|
| def list_mcp_servers(self) -> dict[str, list[str]]: |
| """List all MCP servers and their tools.""" |
| return self.tool_manager.list_mcp_servers() |
|
|
| def show_mcp_status(self) -> None: |
| """Display detailed MCP status information to the user.""" |
| self.tool_manager.show_mcp_status() |
|
|
| def get_mcp_summary(self) -> dict[str, any]: |
| """Get a summary of MCP tools for programmatic access.""" |
| return self.tool_manager.get_mcp_summary() |
|
|
| |
| |
| |
|
|
| def get_tool_statistics(self) -> dict[str, any]: |
| """Get comprehensive tool statistics.""" |
| return self.tool_manager.get_tool_statistics() |
|
|
| def validate_tools(self) -> dict[str, list[str]]: |
| """Validate all tools and return any issues.""" |
| return self.tool_manager.validate_tools() |
|
|
| |
| |
| |
|
|
| def reset_tool_selection(self): |
| """Reset the cached tool selection to allow re-selection on next query.""" |
| self._selected_tools_cache = None |
| if self.use_tool_selection: |
| self.console.console.print( |
| "🔄 Tool selection cache cleared - will re-select tools on next query" |
| ) |
|
|
| def get_selected_tools(self): |
| """Get the currently selected tools (if any).""" |
| return list(self._selected_tools_cache.keys()) if self._selected_tools_cache else None |
|
|
| |
| |
| |
|
|
| def set_principal(self, principal: str | None) -> None: |
| """Set the authenticated caller recorded in the next trace (ADR-0012). |
| |
| The orchestrator (front door) establishes identity via HF OAuth and |
| forwards it here; the specialist trusts that forwarded principal (it is |
| reached only through the orchestrator's service token — ADR-0012 |
| decision 2). ``None`` clears it back to anonymous. |
| """ |
| self.principal = principal |
|
|
| def get_trace(self) -> dict: |
| """Get the complete trace of the last execution.""" |
| if not self.workflow_engine: |
| return {} |
|
|
| messages = self.workflow_engine.message_history |
|
|
| |
| |
| |
| |
| |
| |
| if not messages: |
| messages = [ |
| self.workflow_engine._serialize_message(m) |
| for m in getattr(self.workflow_engine, "last_state_messages", []) or [] |
| ] |
|
|
| |
| |
| |
| try: |
| metrics = compute_run_metrics(messages) |
| except Exception as exc: |
| metrics = {"error": f"run_metrics failed: {exc}"} |
|
|
| return { |
| "execution_time": time.strftime("%Y-%m-%d %H:%M:%S"), |
| "metrics": { |
| "step_count": getattr(self.workflow_engine, "last_step_count", None), |
| "end_reason": getattr(self.workflow_engine, "last_end_reason", None), |
| **metrics, |
| }, |
| "config": { |
| "max_steps": self.config.max_steps, |
| "timeout_seconds": self.config.timeout_seconds, |
| "verbose": self.config.verbose, |
| |
| |
| |
| **principal_trace_fields(self.principal), |
| }, |
| "messages": messages, |
| "trace_logs": self.workflow_engine.trace_logs, |
| } |
|
|
| def get_summary(self) -> dict: |
| """Get a summary of the last execution.""" |
| if not self.workflow_engine: |
| return {} |
| return self.workflow_engine.generate_summary() |
|
|
| def save_trace(self, filepath: str = None) -> str: |
| """Save the trace of the last execution to a file.""" |
| if not self.workflow_engine: |
| raise RuntimeError("No workflow engine available") |
| return self.workflow_engine.save_trace_to_file(filepath) |
|
|
| def save_summary(self, filepath: str = None) -> str: |
| """Save the summary of the last execution to a file.""" |
| if not self.workflow_engine: |
| raise RuntimeError("No workflow engine available") |
| return self.workflow_engine.save_summary_to_file(filepath) |
|
|
| |
| |
| |
|
|
| def run( |
| self, |
| query: str, |
| save_trace: bool = False, |
| save_summary: bool = False, |
| trace_dir: str = "traces", |
| principal: str | None = None, |
| ) -> str: |
| """ |
| Run the agent with a given query using modular components. |
| |
| Args: |
| query: The task/question to solve |
| save_trace: Whether to save the complete trace to a file |
| save_summary: Whether to save the execution summary to a file |
| trace_dir: Directory to save trace and summary files |
| principal: Authenticated caller for this run (ADR-0012); recorded in |
| the audit trace. Back-compat default None → "anonymous". |
| |
| Returns: |
| The final response content |
| """ |
| |
| |
| |
| if principal is not None: |
| self.principal = principal |
|
|
| |
| overall_timing = Timing(start_time=time.time()) |
|
|
| |
| self.console.print_task_header(query) |
|
|
| |
| self._prepare_executor() |
|
|
| |
| stats = self.tool_manager.get_tool_statistics() |
| mcp_servers = self.tool_manager.list_mcp_servers() |
| self.console.console.print(f"🛠️ Loaded {stats['total_tools']} total tools:") |
| if stats["by_source"]["decorated"] > 0: |
| self.console.console.print(f" 🎯 Decorated tools: {stats['by_source']['decorated']}") |
| if stats["by_source"]["mcp"] > 0: |
| self.console.console.print( |
| f" 🔗 MCP tools: {stats['by_source']['mcp']} from {len(mcp_servers)} servers" |
| ) |
| for server_name, tools in mcp_servers.items(): |
| self.console.console.print(f" • {server_name}: {len(tools)} tools") |
|
|
| |
| input_state = create_agent_state( |
| messages=[HumanMessage(content=query)], |
| step_count=0, |
| error_count=0, |
| start_time=time.time(), |
| current_plan=None, |
| ) |
|
|
| |
| result, final_state = self.workflow_engine.run_workflow(input_state) |
|
|
| |
| overall_timing.end_time = time.time() |
|
|
| |
| final_step_count = final_state.get("step_count", 0) if final_state else 0 |
| final_error_count = final_state.get("error_count", 0) if final_state else 0 |
|
|
| self.console.print_execution_summary( |
| final_step_count, final_error_count, overall_timing.duration |
| ) |
|
|
| |
| |
| |
| |
| |
| try: |
| from core.trace_redaction import redact_trace_safe |
| from logging_sink import get_log_sink, persist_trace_safe |
|
|
| run_id = time.strftime("%Y%m%d_%H%M%S") |
| |
| |
| |
| |
| redacted = redact_trace_safe(self.get_trace(), run_id) |
| persist_trace_safe(get_log_sink(), run_id, redacted) |
| except Exception as e: |
| print(f"[log_sink] trace persistence skipped: {e}") |
|
|
| |
| |
| if save_trace or save_summary: |
| |
| from pathlib import Path |
|
|
| trace_path = Path(trace_dir) |
| trace_path.mkdir(parents=True, exist_ok=True) |
|
|
| if save_trace: |
| trace_file = trace_path / f"agent_trace_{time.strftime('%Y%m%d_%H%M%S')}.json" |
| saved_trace = self.workflow_engine.save_trace_to_file(str(trace_file)) |
| self.console.console.print(f"💾 Trace saved to: {saved_trace}") |
|
|
| if save_summary: |
| summary_file = trace_path / f"agent_summary_{time.strftime('%Y%m%d_%H%M%S')}.json" |
| saved_summary = self.workflow_engine.save_summary_to_file(str(summary_file)) |
| self.console.console.print(f"📊 Summary saved to: {saved_summary}") |
|
|
| return result |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| """ |
| CLI entry point for local testing. |
| |
| Usage: |
| python src/agent.py |
| python src/agent.py "Tell me about the Moffitt PDAC dataset." |
| python src/agent.py "Run differential expression on the Moffitt dataset." |
| |
| Requires: |
| - Anthropic_API_KEY environment variable (or .env file at project root) |
| - mcp_config.yaml at project root |
| - R + limma installed locally for method=limma (optional; |
| method=ttest works without R) |
| """ |
| import sys |
|
|
| from dotenv import load_dotenv |
| from langchain_anthropic import ChatAnthropic |
|
|
| |
| project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| load_dotenv(os.path.join(project_root, ".env")) |
|
|
| |
| query = ( |
| " ".join(sys.argv[1:]) |
| if len(sys.argv) > 1 |
| else ("Tell me about the Moffitt PDAC dataset and what analyses are supported.") |
| ) |
|
|
| api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("Anthropic_API_KEY") |
| if not api_key: |
| print("ANTHROPIC_API_KEY not set. Export it or add it to .env at the project root.") |
| sys.exit(1) |
|
|
| model = ChatAnthropic( |
| model="claude-sonnet-4-5-20250929", |
| temperature=0, |
| api_key=api_key, |
| ) |
|
|
| config = AgentConfig( |
| max_steps=15, |
| retry_attempts=3, |
| timeout_seconds=1200, |
| verbose=True, |
| ) |
|
|
| agent = CodeAgent(model=model, config=config) |
|
|
| |
| mcp_config = os.path.join(project_root, "mcp_config.yaml") |
| if os.path.exists(mcp_config): |
| try: |
| agent.add_mcp(mcp_config) |
| stats = agent.get_tool_statistics() |
| print(f"Loaded {stats['total_tools']} tools ({stats['by_source']['mcp']} MCP)") |
| except Exception as e: |
| print(f"MCP tools could not be loaded: {e}") |
| print(" Analysis will run without decoupleRpy tools.") |
| else: |
| print(f"mcp_config.yaml not found at {mcp_config}") |
|
|
| print("\nQuery: " + query + "\n" + "-" * 60) |
| agent.run(query, save_trace=False, save_summary=False) |
| print("-" * 60 + "\nDone.") |
|
|