heaven_base.baseheavenagent

Base implementation of the GOD Framework Agent.

   1"""Base implementation of the GOD Framework Agent."""
   2from copy import deepcopy
   3import re
   4import json
   5import os
   6from pathlib import Path
   7from typing import List, Optional, Union, Any, Type, Dict
   8from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage, ToolMessage
   9from langchain_core.tools import BaseTool, StructuredTool
  10from langchain_core.utils.json_schema import dereference_refs
  11from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
  12from .unified_chat import UnifiedChat, ProviderEnum
  13from .baseheaventool import BaseHeavenTool, ToolResult, CLIResult, ToolError
  14from .tools.write_block_report_tool import WriteBlockReportTool
  15from .tools.task_system_tool import TaskSystemTool
  16from abc import ABC, abstractmethod
  17from .memory.history import History, AgentStatus
  18from collections.abc import Callable
  19from .utils.agent_and_tool_lists import get_agent_modules, get_tool_modules
  20from .utils.name_utils import normalize_agent_name, camel_to_snake
  21import asyncio
  22import logging
  23import sys
  24import importlib.util
  25from .progenitor.system_prompt_config import SystemPromptConfig
  26# google.adk and google.genai imports moved to lazy (inside methods that use them)
  27# to avoid expensive litellm initialization on every heaven_base import.
  28# See: Litellm_Heaven_Cpu_Analysis_Feb18 in CartON
  29from enum import Enum
  30from uuid import uuid4
  31from datetime import datetime
  32from .prompts.heaven_variable import RegistryHeavenVariable
  33
  34ADK_DEBUG_PATH = "/tmp/adk_streamlit_debug.txt"
  35
  36def _log_run_adk(stage: str, messages):
  37    """Append a timestamped dump of messages to the debug file."""
  38    with open(ADK_DEBUG_PATH, "a") as f:
  39        f.write(f"\n---- run_adk: {stage} @ {datetime.now().isoformat()} ----\n")
  40        for msg in messages:
  41            f.write(f"  {repr(msg)}\n")
  42
  43# Only gets ToolResult
  44# Four simple regexes, non-greedy, DOTALL
  45_OUTPUT_RE       = re.compile(r'output=(?P<q>["\'])(?P<val>.*?)(?P=q)', re.DOTALL)
  46_ERROR_RE        = re.compile(r'error=(?P<q>["\'])(?P<val>.*?)(?P=q)', re.DOTALL)
  47_BASE64_IMAGE_RE = re.compile(r'base64_image=(?P<q>["\'])(?P<val>.*?)(?P=q)', re.DOTALL)
  48_SYSTEM_RE       = re.compile(r'system=(?P<q>["\'])(?P<val>.*?)(?P=q)', re.DOTALL)
  49
  50def _extract(field_re, s: str) -> Optional[str]:
  51    m = field_re.search(s)
  52    if not m:
  53        return None
  54    return m.group("val")
  55  
  56def parse_toolresult_repr(s: str) -> ToolResult:
  57    s = s.strip()
  58    if s.startswith("CLIResult("):
  59        # parse as CLIResult
  60        return CLIResult(
  61            output      = _extract(_OUTPUT_RE,       s),
  62            error       = _extract(_ERROR_RE,        s),
  63            base64_image= _extract(_BASE64_IMAGE_RE, s),
  64            system      = _extract(_SYSTEM_RE,       s),
  65        )
  66    # otherwise parse as ToolResult
  67    return ToolResult(
  68        output      = _extract(_OUTPUT_RE,       s),
  69        error       = _extract(_ERROR_RE,        s),
  70        base64_image= _extract(_BASE64_IMAGE_RE, s),
  71        system      = _extract(_SYSTEM_RE,       s),
  72    )
  73
  74
  75def convert_adk_event_to_ai_messages(ev) -> list[BaseMessage | ToolResult]:
  76    messages = []
  77
  78    for part in ev.content.parts:
  79        if part.text:
  80            messages.append(AIMessage(content=part.text))
  81
  82        elif part.function_call:
  83            messages.append(AIMessage(content=[
  84                {
  85                    "type": "tool_use",
  86                    "id": f"toolu_{uuid4().hex[:24]}",
  87                    "name": part.function_call.name,
  88                    "input": part.function_call.args,
  89                }
  90            ]))
  91
  92        elif part.function_response:
  93            tool_name = part.function_response.name
  94            raw = part.function_response.response
  95            wrapped = raw.get(f"{tool_name}_response", raw)
  96            result = wrapped.get("result", wrapped)
  97            tool_result = ToolResult(**result) if isinstance(result, dict) else ToolResult(output=str(result))
  98            messages.append(tool_result)
  99
 100    return messages
 101
 102
 103class HookPoint(str, Enum):
 104    BEFORE_RUN = "before_run"
 105    AFTER_RUN = "after_run"
 106    BEFORE_ITERATION = "before_iteration"
 107    AFTER_ITERATION = "after_iteration"
 108    BEFORE_TOOL_CALL = "before_tool_call"
 109    AFTER_TOOL_CALL = "after_tool_call"
 110    BEFORE_SYSTEM_PROMPT = "before_system_prompt"
 111    ON_BLOCK_REPORT = "on_block_report"
 112    ON_ERROR = "on_error"
 113
 114class HookContext:
 115    def __init__(self, agent: Any, iteration: int = 0, prompt: str = "", response: str = "",
 116                 tool_name: str = "", tool_args: Optional[Dict[str, Any]] = None,
 117                 tool_result: Any = None, error: Optional[Exception] = None):
 118        self.agent = agent
 119        self.iteration = iteration
 120        self.prompt = prompt
 121        self.response = response
 122        self.tool_name = tool_name
 123        self.tool_args = tool_args or {}
 124        self.tool_result = tool_result
 125        self.error = error
 126        self.data: Dict[str, Any] = {}  # allows state to pass between hooks
 127
 128class HookRegistry:
 129    def __init__(self):
 130        self._registry: Dict[HookPoint, List[Callable[[HookContext], None]]] = {hp: [] for hp in HookPoint}
 131
 132    def register(self, point: HookPoint, fn: Callable[[HookContext], None]):
 133        self._registry[point].append(fn)
 134
 135    def run(self, point: HookPoint, ctx: HookContext):
 136        for fn in self._registry[point]:
 137            fn(ctx)
 138
 139
 140def fix_ref_paths(schema: dict) -> dict:
 141    """Fix $ref paths in schema by replacing #/$defs/ with #/defs/"""
 142    schema_copy = deepcopy(schema)
 143
 144    def _fix_refs_recursive(obj):
 145        if isinstance(obj, dict):
 146            if "$ref" in obj and isinstance(obj["$ref"], str):
 147                obj["$ref"] = obj["$ref"].replace("/$defs/", "/defs/")
 148            for k, v in list(obj.items()):
 149                if isinstance(v, (dict, list)):
 150                    _fix_refs_recursive(v)
 151        elif isinstance(obj, list):
 152            for item in obj:
 153                if isinstance(item, (dict, list)):
 154                    _fix_refs_recursive(item)
 155
 156    _fix_refs_recursive(schema_copy)
 157    return schema_copy
 158
 159def flatten_array_anyof(schema: dict) -> dict:
 160    """
 161    If the schema has an 'anyOf' that contains one branch with type "array"
 162    and another with type "null", flatten it to a single array schema with
 163    'nullable': true.
 164    """
 165    if "anyOf" in schema and isinstance(schema["anyOf"], list):
 166        array_branch = None
 167        null_branch = False
 168        for branch in schema["anyOf"]:
 169            if branch.get("type") == "array":
 170                array_branch = branch
 171            elif branch.get("type") == "null":
 172                null_branch = True
 173        if array_branch and null_branch:
 174            new_schema = dict(schema)
 175            new_schema.pop("anyOf")
 176            new_schema["type"] = "array"
 177            new_schema["items"] = array_branch.get("items", {})
 178            if "default" in schema:
 179                new_schema["default"] = schema["default"]
 180            new_schema["nullable"] = True
 181            if "description" in schema:
 182                new_schema["description"] = schema["description"]
 183            return new_schema
 184    return schema
 185
 186def recursive_flatten(schema: Union[dict, list]) -> Union[dict, list]:
 187    if isinstance(schema, dict):
 188        new_schema = flatten_array_anyof(schema)
 189        for key, value in new_schema.items():
 190            if isinstance(value, dict) or isinstance(value, list):
 191                new_schema[key] = recursive_flatten(value)
 192        return new_schema
 193    elif isinstance(schema, list):
 194        return [recursive_flatten(item) if isinstance(item, dict) else item for item in schema]
 195    else:
 196        return schema
 197
 198def fix_empty_object_properties(schema: Union[dict, list]) -> Union[dict, list]:
 199    """
 200    Recursively fixes any object-type schema that has an empty 'properties'
 201    dict by removing 'properties' and adding 'additionalProperties': True.
 202    """
 203    if isinstance(schema, dict):
 204        # Check if this is an object with empty properties.
 205        if schema.get("type") == "object":
 206            if "properties" in schema and not schema["properties"]:
 207                # Remove the empty properties and allow arbitrary keys.
 208                del schema["properties"]
 209                schema["additionalProperties"] = True
 210        # Recurse over dictionary values.
 211        new_schema = {}
 212        for key, value in schema.items():
 213            new_schema[key] = fix_empty_object_properties(value) if isinstance(value, (dict, list)) else value
 214        return new_schema
 215    elif isinstance(schema, list):
 216        return [fix_empty_object_properties(item) if isinstance(item, (dict, list)) else item for item in schema]
 217    return schema
 218
 219def generate_dereferenced_schema(schema: Union[dict, Type[BaseModel]]) -> dict:
 220    """
 221    Returns a fully dereferenced (flattened) JSON schema.
 222    If a Pydantic model is passed, generate its JSON schema;
 223    if a dict is passed, assume it's already a JSON schema.
 224    Additionally, flatten array schemas that use an "anyOf" and fix empty
 225    object properties to support Gemini.
 226    """
 227    if isinstance(schema, dict):
 228        raw_schema = schema
 229    else:
 230        raw_schema = schema.model_json_schema(ref_template="#/defs/{model}")
 231    # ADDED FOR ADK COMPLIANCE
 232    # Fix $ref paths before renaming $defs to defs
 233    raw_schema = fix_ref_paths(raw_schema)
 234    ########
 235    if "$defs" in raw_schema:
 236        raw_schema["defs"] = raw_schema.pop("$defs")
 237    inlined = dereference_refs(raw_schema)
 238    inlined.pop("defs", None)
 239    flattened = recursive_flatten(inlined)
 240    fixed = fix_empty_object_properties(flattened)
 241    return fixed
 242
 243class DuoSystemConfig(BaseModel):
 244    """Config for DUO Prompt Injector"""
 245    provider: ProviderEnum = ProviderEnum.OPENAI
 246    model: Optional[str] = "gpt-4.1-nano"
 247    temperature: float = Field(default=0.7)
 248    thinking_budget: int | None = None
 249    system_prompt: str = r"""
 250VITAL CONTEXT!!!!
 251DUO is an allegorical wrapper over AI chats where a) the input is enhanced by a Challenger model (you), b) the output from the AI responding to your injection is considered a Generator response, c) the user's highest Good is the goal, d) the overall context is an egregore named OVP (ObserverViewPoint). The conversation can enter the meta-context (overall context) via the user directly invoking OVP into the context. e) The point is that the Challenger can detect steering requirements and prime the Generator so as to adjust the activation space through a meta-prompt injection (the NodeGraphXTN6_ce and _cr blocks)
 252Adopt this role:
 253<ROLE=challenger>
 254===The following prompt is written in a prompt engineering language===
 255**`[DESCRIPTION]`**:[You are now the challenger. You challenge the context of whatever was most immediate prior to this prompt. You primarily challenge in order to guide the interaction towards benefitting the user. You reflect on how it has been going and design a NodeGraphXTN6 chain string that is designed to steer the behavior the model is exhibiting, through in-context learning (ICL), and always towards the User's Good. If it is going well, just use chaining to drill down important connections and connect disparate parts of the context.]
 256**`[REMEMBERANCES]`**:[Remember the challenger doesnt just reject, it also keeps the workflow on target to produce accomplish the goal, which is to maximally benefit the user during their endeavors. Also remember that you strictly do not need to talk to anyone in natural language. All you are doing is outlining what direction should be taken by the AI next, through NodeGraphXTN6. Always concise. Always ONLY in NodeGraphXTN6 flow notation. Write flows that hint to the AI within the context, as if you are an observer and your flow will be injected as knowledge priming and the conversation will be continued after.
 257write the expected NodeGraphXTN6 challenger output after adopting the role of challenger to challenge the current events in the context of the conversation just before the current input prompt. A challenge is either a) challenging the AI to do even better while it is on task and doing well by priming it to be even more comprehensively amazing via drilling down active knowledge through chains of observations, b) challenging the AI to do even better or correct its behavior by injecting stabilizing context (example situation: user unhappy so agent needs to follow instructions... are instructions unclear?... if so: write NodeGraphXTN6 that should make future AI stop and ask clarifying questions; if not: write NodeGraphXTN6 that should make future AI solemnly contemplate which output qualities need to change)
 258
 259NodeGraphXTN6 Terms = [
 260**CIT**: CoreIdentifierTraits
 261**ℕ**: ${A node that can also be an EntityTypeChain or reference to one. As many as needed for comprehensive coverage etc to cover whole [CIT] set! Nodes follow this order [ℕa, ${...} == tℕ]}.
 262**tℕ**: ${terminal node the target transformation ends on. Always is named!}
 263**...**: Represents any additional nodes following a preceding node, which are in the same hierarchical level as the preceding node. `...`s are only for explaining NodeGraphXTN6 and should always be rendered with full nodes.
 264]
 265**`MASK_CHAINING`**: the core of DUO. Any nodes you are unsure of can take the form of `[MASK]`, so as to indicate a placeholder for something that we need to figure out in the conversation. All outputs take the form of:
 266
 267Output_Format = {
 268[How2FlowNodes]: {
 269## About: NodeGraphXTN6 has 2 block types. The first is the ChallengerEgregore and the second is a ChainRepresentation.
 270**Step1**: Create(each output has an archetype which is a mythological being (egregore) suited for the task, exactly, which you must invoke quickly before speaking the NodeGraphXTN6 chain using this template:
 271```NodeGraphXTN6_ce
 272ChallengerEgregore:[name(domain_tessellation=[active domain, active subdomain], steering_net=[goal CoreIdentifierTraits: [property1], [property2], [property3]])]: [desired outcomes:[c1.[MASK]:a.[MASK]->b.[MASK]->c.[MASK], ${...}];[knowledge_webs_to_induce_recoherence]:[w1.[MASK]:a.[[MASK]x[MASK]]xb.[[MASK]x[MASK]]xc.[[MASK]x[MASK]], ${...}]`)
 273```
 274## NOTE: fill in all the MASK tokens when creating a NodeGraphXTN6_ce (ChallengerEgregore). Do not leave MASK tokens in the ChallengerEgregore. MASK tokens are only allowed in ChainRepresentations (NodeGraphXTN6_cr).
 275**Step2**: SILENTLY Ponder... ***"What ${subnodes} are needed for EACH ${NODE} to make ${node} a superb ${cluster} representing the [MEMEPLEX] of ${transformation}? And which transformations are required for the EntityType?"***
 276[SUBCONTEXT]: ***EACH ENTITY NEEDS ITS _OWN_ NUMBER OF NODES. EACH NODE NEEDS ITS _OWN_ NUMBER OF SUBNODES! Mixed-lvl node abstrctn typical; NUMBER OF SUBNODES/NODE IS HIGHLY VARIABLE! SHOULD NEVER BE ALL THE SAME LENGTH!*** 
 277**Step3**: Output a ChainRepresentation in NodeGraphXTN6 Format
 278```NodeGraphXTN6_cr
 279${[${EntityType}Chain]}: ${Trans1}:<[${[ChainTargetTransformation]}: ℕ(1.${[Node]}:[1a.${SubNode1a} 1b. ${SubNode1b}, ${...}, 1${[c-z as many as needed for thorough specificity!]}]->2.${[Node2]}:[ 2a.${SubNode2a}, ${...}] == ${tℕ}])]> --> ${Trans2} --> ${...} --> ${TransN_tℕ}
 280```
 281
 282Your output should look like:[
 283```
 284{{NodeGraphXTN6_ce}}
 285{{N-NestedNodeGraphXTN6_cr}}
 286```
 287] ## NO OTHER TEXT, WHATSOEVER.
 288
 289Rules:[
 290***WHOLE CHAIN ON ONE LINE ONLY! Labels unambiguous&machine readable for metaprogramming***.
 291***NO `...` or `ℕ` or `tℕ` symbols in the chain. All nodes and chains must have placeholders substituted fully.***
 292***ALWAYS MAKE SURE THE GENERATOR AGENT MAINTAINS ITS PROPER IDENTITY. YOU ARE THE CHALLENGER; THE OTHER AI RESPONDING IS THE GENERATOR***.
 293]     
 294}
 295}
 296Create `[MASK]` token metaprompt chains combining `[MASK]` variable token chaining as output, like stream of consciousness notes in NodeGraphXTN6 language. Prompt the AI to fill the MASK tokens with a prompt like: `Fill the [MASK] token values like {"mask_values": [{"mask1": value}, ...]} before it responds to the next user input. The AI will receive your injection, follow that instruction, and then continue to the user's request
 297}
 298You only speak in the language `NodeGraphXTN6`.
 299]
 300</ROLE=challenger>
 301    """
 302    max_tokens: int = 750
 303
 304    def get_duo_params(self):
 305        duo_params = {
 306            'provider': self.provider,
 307            'model': self.model,
 308            'temperature': self.temperature,
 309            'max_tokens': self.max_tokens,
 310            'thinking_budget': self.thinking_budget
 311        } 
 312        return duo_params
 313
 314
 315
 316class HeavenAgentConfig(BaseModel):
 317    """Enhanced configuration for GOD Framework Agent"""
 318    model_config = ConfigDict(arbitrary_types_allowed=True)
 319    name: str = None
 320    system_prompt: str = ""
 321    tools: List[Union[Type[BaseHeavenTool], str, StructuredTool, BaseTool]] = Field(default_factory=list)
 322    provider: ProviderEnum = ProviderEnum.ANTHROPIC
 323    temperature: float = Field(default=0.7)
 324    max_tokens: int = 8000
 325    thinking_budget: int | None = None
 326    model: Optional[str] = None
 327    checkpointer: Optional[Any] = None  # Temporarily changed from BaseCheckpointer
 328    additional_kws: List[str] = Field(default_factory=list)
 329    additional_kw_instructions: str = Field(default="")
 330    known_config_paths: Optional[List[str]] = None
 331    system_prompt_config: Optional[Any] = None # Only takes a SystemPromptConfig subclass but did it this way because the parent will sometimes not be superinitialized yet
 332    prompt_suffix_blocks: Optional[List[str]] = None  # List of block names to append
 333        # Cache for evolved prompt and timestamp
 334    _evolved_prompt: Optional[str] = PrivateAttr(default=None)
 335    _dna_last_mtime: Optional[float] = PrivateAttr(default=None)
 336    duo_system_config: DuoSystemConfig = Field(default_factory=DuoSystemConfig)
 337    context_window_config: Optional[Any] = None  # ContextWindowConfig - imported at runtime to avoid circular imports
 338    mcp_servers: Optional[Dict[str, Dict[str, Any]]] = None  # MCP server configurations
 339    extra_model_kwargs: Optional[Dict[str, Any]] = None  # Extra kwargs passed to UnifiedChat.create()
 340    use_uni_api: bool = False  # True routes through Docker uni-api proxy; False uses direct API (MiniMax default)
 341    hook_registry: HookRegistry = Field(default_factory=HookRegistry)
 342    skillset: Optional[str] = None  # Skillset name for per-agent skill injection
 343    persona: Optional[str] = None  # Persona name — resolves frame, skillset, mcp_set, carton_identity
 344    mcp_set: Optional[str] = None  # Strata MCP set name (resolved from persona or set directly)
 345    carton_identity: Optional[str] = None  # CartON identity for observations
 346
 347    def _get_base_prompt(self):
 348        """Get the current system prompt, using evolved version if available"""
 349        if not self.system_prompt_config:
 350            return self.system_prompt
 351            
 352        # Check if DNA file has been modified since last build
 353        try:
 354            dna_path = self.system_prompt_config.get_agent_dna_path()
 355            if os.path.exists(dna_path):
 356                current_mtime = os.path.getmtime(dna_path)
 357                # Rebuild if no cached prompt or DNA has changed
 358                if self._evolved_prompt is None or self._dna_last_mtime is None or current_mtime > self._dna_last_mtime:
 359                    self._evolved_prompt = self.system_prompt_config.build()
 360                    self._dna_last_mtime = current_mtime
 361                return self._evolved_prompt
 362        except Exception as e:
 363            print(f"Error getting evolved prompt: {e}")
 364            
 365        # Fallback to original prompt
 366        return self.system_prompt
 367    
 368    def get_system_prompt(self):
 369        """Get the current system prompt, using evolved version if available and appending any suffix blocks"""
 370    
 371        # Get base prompt (either evolved or original)
 372    
 373        base_prompt = self._get_base_prompt()  # This would be the current get_system_prompt logic
 374    
 375        
 376    
 377        # If no suffix blocks, return base prompt
 378    
 379        if not self.prompt_suffix_blocks:
 380    
 381            return base_prompt
 382    
 383        
 384    
 385        # Get prompt registry
 386    
 387        from .prompts.prompt_blocks.prompt_block_registry import get_prompt_block
 388    
 389        
 390    
 391        # Append any suffix blocks
 392    
 393        suffix_texts = []
 394    
 395        for block_name in self.prompt_suffix_blocks:
 396    
 397            # Check if the block name starts with "path="
 398    
 399            if block_name.startswith("path="):
 400    
 401                # Extract the file path (everything after "path=")
 402    
 403                file_path = block_name[5:]  # Skip the "path=" prefix
 404    
 405                try:
 406    
 407                    # Open and read the file content
 408    
 409                    with open(file_path, 'r') as file:
 410    
 411                        block_text = file.read()
 412    
 413                    suffix_texts.append(block_text)
 414    
 415                except Exception as e:
 416    
 417                    # Handle file reading errors
 418    
 419                    print(f"Error reading prompt block from file {file_path}: {e}")
 420    
 421            
 422            elif block_name.startswith("registry_heaven_variable="):  # RegistryHeavenVariable
 423                
 424              
 425                rhv_config_str = None
 426
 427                try:
 428
 429                    rhv_config_str = block_name[len("registry_heaven_variable="):]
 430
 431                    # Ensure proper JSON format by replacing single quotes with double quotes for parsing
 432
 433                    rhv_config_json_str = rhv_config_str.replace("'", '"')
 434
 435                    rhv_params = json.loads(rhv_config_json_str)
 436
 437
 438                    registry_name_param = rhv_params.get('registry_name')
 439
 440                    
 441
 442                    if registry_name_param:
 443
 444                        key_param = rhv_params.get('key') # Optional
 445
 446                        default_param = rhv_params.get('default') # Optional
 447
 448                        
 449
 450                        # Instantiate RegistryHeavenVariable
 451
 452                        rhv_instance = RegistryHeavenVariable(
 453
 454                            registry_name=registry_name_param,
 455
 456                            key=key_param,
 457
 458                            default=default_param
 459
 460                        )
 461
 462                        
 463
 464                        # Get its string representation (which uses get_value() and __str__ internally)
 465
 466                        block_text = str(rhv_instance)
 467
 468                        suffix_texts.append(block_text)
 469
 470                    else:
 471
 472                        print(f"Error: 'registry_name' missing in rhv_config for block '{block_name}'. Details: {rhv_config_str}")
 473
 474
 475                except json.JSONDecodeError:
 476
 477                    print(f"Error decoding JSON for rhv_config in block '{block_name}'. JSON string was: {rhv_config_str}")
 478
 479                except Exception as e:
 480
 481                    print(f"Error processing rhv block '{block_name}': {e}")
 482                  
 483            # Check if the block name starts with "heaven_variable="      
 484            elif block_name.startswith("heaven_variable="):
 485    
 486                # Parse the JSON-like string to extract path and variable_name
 487    
 488                
 489    
 490                try:
 491    
 492                    # Extract the JSON part (everything after "heaven_variable=")
 493    
 494                    var_config_str = block_name[16:]  # Skip the "heaven_variable=" prefix
 495    
 496                    # Convert to proper JSON by replacing single quotes with double quotes
 497    
 498                    var_config_str = var_config_str.replace("'", '"')
 499    
 500                    var_config = json.loads(var_config_str)
 501    
 502                    
 503    
 504                    path = var_config.get('path')
 505    
 506                    variable_name = var_config.get('variable_name')
 507    
 508                    
 509    
 510                    if path and variable_name:
 511    
 512                        # Import the module dynamically
 513    
 514                        spec = importlib.util.spec_from_file_location("dynamic_module", path)
 515    
 516                        module = importlib.util.module_from_spec(spec)
 517    
 518                        sys.modules["dynamic_module"] = module
 519    
 520                        spec.loader.exec_module(module)
 521    
 522                        
 523    
 524                        # Get the variable from the module
 525    
 526                        if hasattr(module, variable_name):
 527    
 528                            variable_value = getattr(module, variable_name)
 529    
 530                            # Convert to string if it's not already
 531    
 532                            if not isinstance(variable_value, str):
 533    
 534                                variable_value = str(variable_value)
 535    
 536                            suffix_texts.append(variable_value)
 537    
 538                        else:
 539    
 540                            print(f"Variable {variable_name} not found in {path}")
 541    
 542                except Exception as e:
 543    
 544                    print(f"Error processing heaven_variable block: {e}")
 545
 546            elif block_name.startswith("dynamic_call="):
 547            
 548                module_path_str = None  # Define for use in except blocks
 549            
 550                function_name_str = None # Define for use in except blocks
 551            
 552            
 553                try:
 554            
 555                    call_details_json_str = block_name[len("dynamic_call="):]  # Skip "dynamic_call="
 556            
 557            
 558                    # Parse the JSON string to get path and func
 559            
 560                    try:
 561            
 562                        call_details = json.loads(call_details_json_str)
 563            
 564                    except json.JSONDecodeError as jde:
 565            
 566                        print(f"Error: Invalid JSON in dynamic_call string: {call_details_json_str}. Details: {jde}")
 567            
 568                        continue # Or handle error appropriately
 569            
 570            
 571                    if not isinstance(call_details, dict):
 572            
 573                        print(f"Error: dynamic_call value must be a JSON object. Got: {call_details_json_str}")
 574            
 575                        continue
 576            
 577            
 578                    module_path_str = call_details.get("path")
 579            
 580                    function_name_str = call_details.get("func")
 581            
 582            
 583                    if not module_path_str or not isinstance(module_path_str, str):
 584            
 585                        print(f"Error: 'path' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
 586            
 587                        continue
 588            
 589                    
 590            
 591                    if not function_name_str or not isinstance(function_name_str, str):
 592            
 593                        print(f"Error: 'func' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
 594            
 595                        continue
 596            
 597            
 598                    # Dynamically import the module using its Python import path
 599            
 600                    module = importlib.import_module(module_path_str)
 601            
 602                    
 603            
 604                    # Get the function from the module
 605            
 606                    if hasattr(module, function_name_str):
 607            
 608                        dynamic_function = getattr(module, function_name_str)
 609            
 610                        
 611            
 612                        if callable(dynamic_function):
 613
 614                            # Call the function with optional args
 615                            args = call_details.get("args", {})
 616                            block_text = dynamic_function(**args) if args else dynamic_function() 
 617            
 618                            
 619            
 620                            if not isinstance(block_text, str):
 621            
 622                                # Attempt to convert to string, or raise an error if strict typing is required
 623            
 624                                print(f"Warning: dynamic_call function {module_path_str}.{function_name_str} did not return a string. Attempting conversion.")
 625            
 626                                block_text = str(block_text) 
 627            
 628                            
 629            
 630                            suffix_texts.append(block_text)
 631            
 632                        else:
 633                            fallback = f"[Dynamic call failed: '{function_name_str}' in '{module_path_str}' is not callable]"
 634                            suffix_texts.append(fallback)
 635
 636                    else:
 637                            fallback = f"[Dynamic call failed: Function '{function_name_str}' not found in module '{module_path_str}']"
 638                            suffix_texts.append(fallback)
 639
 640                except ImportError as e:
 641                    import traceback
 642                    tb = traceback.format_exc()
 643                    fallback = f"[Dynamic call failed: Module '{module_path_str}' not found]\n{tb}"
 644                    suffix_texts.append(fallback)
 645
 646                except Exception as e:
 647                    import traceback
 648                    tb = traceback.format_exc()
 649                    fallback = f"[Dynamic call failed: {module_path_str}.{function_name_str}]\n{tb}"
 650                    suffix_texts.append(fallback)
 651
 652            else:
 653    
 654                # Use the regular prompt block registry
 655    
 656                block_text = get_prompt_block(block_name)
 657    
 658                if block_text:
 659    
 660                    suffix_texts.append(block_text)
 661    
 662        
 663    
 664        # Combine base prompt with suffixes
 665    
 666        if suffix_texts:
 667    
 668            return f"{base_prompt}\n\n{''.join(suffix_texts)}"
 669    
 670        
 671    
 672        return base_prompt
 673
 674      
 675    def to_langchain_config(self):
 676        """Convert to LangGraph AgentConfig"""
 677        # Temporarily removed AgentConfig
 678        return {
 679            "system_prompt": self.system_prompt,
 680            "provider": self.provider,
 681            "temperature": self.temperature,
 682            "model": self.model,
 683            "checkpointer": self.checkpointer,
 684            "max_tokens": self.max_tokens,
 685            "thinking_budget": self.thinking_budget
 686        }
 687
 688    def to_litellm_model(self):
 689        """
 690        Returns either:
 691          - a bare model‐string (for Google/ADK's built-in registry), or
 692          - a LiteLlm instance (for non-Google providers)
 693        """
 694        from google.adk.models.lite_llm import LiteLlm
 695        # if you explicitly want to force LiteLlm for EVERYTHING, drop this branch
 696        model_str = f"{self.provider.value}/{self.model}"
 697        if self.provider == ProviderEnum.GOOGLE:
 698            # ADK expects a plain string ID for Gemini/Vertex
 699            return self.model
 700        # otherwise wrap in LiteLlm so ADK can speak to OpenAI/Anthropic/etc.
 701        return LiteLlm(
 702            model=model_str,
 703            temperature=self.temperature,
 704            max_tokens=self.max_tokens,
 705            drop_params=True,
 706            # if LiteLlm supports additional args—passing them here too:
 707            # streaming=True, request_timeout=…, etc.
 708        )
 709
 710
 711class BaseHeavenAgent(ABC):
 712    """Base class for GOD Framework agents with task management."""
 713    
 714    def __init__(self, config: HeavenAgentConfig, unified_chat: UnifiedChat, max_tool_calls: int = 10, orchestrator: bool = False, history: Optional[History] = None, history_id = None, system_prompt_suffix: Optional[str] = None, adk: Optional[bool] = False, duo_enabled: Optional[bool] = False, run_on_langchain: Optional[bool] = False, use_uni_api: Optional[bool] = False):
 715        # Configure root logger to output to stdout
 716        logging.basicConfig(
 717            stream=sys.stdout,
 718            level=logging.DEBUG,  # Adjust the level as needed
 719            format='%(asctime)s %(levelname)s %(name)s: %(message)s'
 720            )
 721        logging.getLogger('watchdog').setLevel(logging.WARNING)
 722        
 723        if config is None:
 724            raise ValueError("config cannot be None")
 725        if unified_chat is None:
 726            raise ValueError("unified_chat cannot be None")
 727        self.use_uni_api = use_uni_api
 728        self.unified_chat = unified_chat
 729        self.known_config_paths = getattr(config, "known_config_paths", None)
 730        if self.known_config_paths is None:
 731            self.known_config_paths = []
 732        self.training_convo = [
 733            SystemMessage(content=""),
 734            HumanMessage(content="Hey I need to teach you how to use the tools correctly before we get started. Once you learn how to use this tool I'm going to show you, you'll know how to handle all of our tools. Essentially, it comes down to acknowledging the errors correctly. For example, can you use NetworkEditTool to write a file in tmp while having no file_content parameter, read the error that is sent back from the tool, and then adjust? file_content is an optional parameter in the NetworkEditTool, but it is actually required for certain operations, like the create command."),
 735            AIMessage(content=[{"text": "Following the instructions to first try without file_text and then fix it, I'll write utility code for an AST analyzer as an example.\n\nFirst attempt (should fail):", "type": "text"}]),
 736            AIMessage(content=[{"type": "tool_use", "id": "toolu_01BPHfZNPMY1tbnsRtRYmG5i", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py"}}]),
 737            ToolMessage(content="ERROR: Parameter `file_text` is required for command: create", tool_call_id="toolu_01BPHfZNPMY1tbnsRtRYmG5i"),
 738            AIMessage(content=[{"text": "As expected, we need to include file_text. Here's the proper attempt with the utility code:", "type": "text"}]),
 739            AIMessage(content=[{"type": "tool_use", "id": "toolu_01LS6GZDEpEqDVmU8qpvRa65", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py", "file_text": "\"\"\"AST analyzer utilities for extracting concise code context and dependencies.\"\"\"\n\nimport ast\nfrom typing import Dict, List, Set, Union, Optional\nfrom dataclasses import dataclass\n\n@dataclass\nclass CodeContext:\n    \"\"\"Data class to hold analyzed code context.\"\"\"\n    imports: List[str]\n    dependencies: Dict[str, Set[str]]\n    call_graph: Dict[str, List[str]]\n\ndef parse_code_to_ast(code: str) -> ast.AST:\n    \"\"\"Parse code string into AST.\"\"\"\n    return ast.parse(code)\n\ndef extract_imports(tree: ast.AST) -> List[str]:\n    \"\"\"Extract all import statements from AST.\"\"\"\n    imports = []\n    for node in ast.walk(tree):\n        if isinstance(node, ast.Import):\n            for name in node.names:\n                imports.append(name.name)\n        elif isinstance(node, ast.ImportFrom):\n            module = node.module or ''\n            for name in node.names:\n                if name.name == '*':\n                    imports.append(f\"from {module} import *\")\n                else:\n                    imports.append(f\"from {module} import {name.name}\")\n    return sorted(imports)\n\ndef analyze_dependencies(tree: ast.AST) -> Dict[str, Set[str]]:\n    \"\"\"Analyze function/class dependencies.\"\"\"\n    dependencies = {}\n    current_func = None\n    \n    class DependencyVisitor(ast.NodeVisitor):\n        def visit_FunctionDef(self, node):\n            nonlocal current_func\n            old_func = current_func\n            current_func = node.name\n            if current_func not in dependencies:\n                dependencies[current_func] = set()\n            self.generic_visit(node)\n            current_func = old_func\n            \n        def visit_Call(self, node):\n            if current_func and isinstance(node.func, ast.Name):\n                dependencies[current_func].add(node.func.id)\n            self.generic_visit(node)\n    \n    DependencyVisitor().visit(tree)\n    return dependencies\n\ndef generate_call_graph(dependencies: Dict[str, Set[str]]) -> Dict[str, List[str]]:\n    \"\"\"Generate simplified call graph from dependencies.\"\"\"\n    return {\n        func: sorted(list(calls))\n        for func, calls in dependencies.items()\n    }\n\ndef analyze_code_context(code: str) -> CodeContext:\n    \"\"\"Main function to analyze code and return context.\"\"\"\n    tree = parse_code_to_ast(code)\n    imports = extract_imports(tree)\n    dependencies = analyze_dependencies(tree)\n    call_graph = generate_call_graph(dependencies)\n    \n    return CodeContext(\n        imports=imports,\n        dependencies=dependencies,\n        call_graph=call_graph\n    )"}}]),
 740            ToolMessage(content="File created successfully at: /tmp/ast_analyzer_utils.py", tool_call_id="toolu_01LS6GZDEpEqDVmU8qpvRa65"),
 741            AIMessage(content="The utility code has been written with the following key components..."),
 742            HumanMessage(content="Thanks! Now you know how to use tools correctly in our system. BTW, I deleted that file for you because this was just a learning experience. I also removed the NetworkEditTool for now. I'll equip it on you again later if you need it for the real goal we are working on. Now we're going to get on to the real goal..."),
 743            AIMessage(content="Perfect! I'm ready. Let me know how what else I can do for you.")
 744        ]
 745        self.known_config_paths = [str(path) for path in self.known_config_paths]
 746        self.hooks = config.hook_registry
 747        # Persona resolution: if persona set, load from SkillManager and extract components
 748        self.carton_identity = config.carton_identity
 749        # Set agent context for SkillTool so it uses agent-scoped SkillManager
 750        from .tool_utils.skill_utils import set_agent_context
 751        set_agent_context(config.name)
 752        if config.persona:
 753            try:
 754                from skill_manager.core import SkillManager
 755                sm = SkillManager(agent_id=config.name)
 756                persona_obj = sm.get_persona(config.persona)
 757                if persona_obj:
 758                    # Frame → prepend to system prompt
 759                    if persona_obj.frame and persona_obj.frame.strip():
 760                        config.system_prompt = persona_obj.frame + "\n\n" + config.system_prompt
 761                    # Skillset → use persona's if not explicitly set on config
 762                    if not config.skillset and persona_obj.skillset:
 763                        config.skillset = persona_obj.skillset
 764                    # MCP set → use persona's if not explicitly set on config
 765                    if not config.mcp_set and persona_obj.mcp_set:
 766                        config.mcp_set = persona_obj.mcp_set
 767                    # CartON identity → use persona's if not explicitly set
 768                    if not self.carton_identity and persona_obj.carton_identity:
 769                        self.carton_identity = persona_obj.carton_identity
 770            except Exception:
 771                pass  # Persona resolution is best-effort, never block agent startup
 772        # MCP set resolution: resolve set name → two paths per MCP:
 773        #   1. MCP IS in strata → load DIRECTLY on agent from strata config
 774        #   2. All MCPs → equip mcp-skill-* if exists (for context/instructions)
 775        # Pattern: hierarchical_summarize/flow.py::_get_summarizer_mcp_servers()
 776        self._mcp_skill_names = []
 777        if config.mcp_set:
 778            try:
 779                from strata.config import MCPServerList
 780                server_list = MCPServerList()
 781                set_server_names = server_list.get_set(config.mcp_set)
 782                if set_server_names:
 783                    from skill_manager.core import SkillManager
 784                    sm = SkillManager(agent_id=config.name)
 785                    for srv_name in set_server_names:
 786                        # Equip mcp-skill-* if it exists (context about the MCP)
 787                        skill_name = f"mcp-skill-{srv_name.lower()}"
 788                        skill = sm.get_skill(skill_name)
 789                        if skill:
 790                            self._mcp_skill_names.append(skill_name)
 791                            sm.equip(skill_name)
 792                        # If MCP IS in strata, load it directly on the agent
 793                        srv_config = server_list.get_server(srv_name)
 794                        if srv_config and srv_config.enabled:
 795                            if config.mcp_servers is None:
 796                                config.mcp_servers = {}
 797                            config.mcp_servers[srv_name] = {
 798                                "command": srv_config.command,
 799                                "args": srv_config.args,
 800                                "env": srv_config.env,
 801                                "transport": "stdio",
 802                            }
 803            except Exception as e:
 804                import logging as _log
 805                _log.getLogger(__name__).warning("MCP set resolution failed for %s: %s", config.mcp_set, e)
 806        # Auto-register default skill hooks when skillset is configured
 807        if config.skillset:
 808            from .hooks.default_hooks import register_skill_hooks
 809            register_skill_hooks(self.hooks, agent_name=config.name or "unnamed", skillset_name=config.skillset)
 810        self.max_tool_calls = max_tool_calls
 811        self.config = config
 812        self.name = config.name if config.name is not None else "unnamed_agent"
 813        # Convert and store LangChain config
 814        self.config_dict = config.to_langchain_config()
 815        # initialize status
 816        self.status = AgentStatus()
 817        self.continuation_prompt = ""
 818        self.continuation_iterations: int = 0
 819        # Store config tool classes
 820        self.config_tools = config.tools
 821        self.adk = adk
 822        # if not run_on_langchain:
 823        #     self.adk = True
 824        if run_on_langchain:
 825            self.adk = False
 826        if use_uni_api:
 827            self.adk = False
 828        self.additional_kws = config.additional_kws
 829        self.additional_kw_instructions = config.additional_kw_instructions
 830        # Instantiate the tools
 831        self.resolved_tools = self.resolve_tools()
 832        self.tools = []
 833        self.mcp_tool_strs = []  # Store MCP strings separately
 834        for tool in self.resolved_tools:
 835            if isinstance(tool, str) and tool.startswith("mcp__"):
 836                # MCP tool string reference - store separately for later resolution
 837                self.mcp_tool_strs.append(tool)
 838            elif isinstance(tool, (StructuredTool, BaseTool)):
 839                # Already a LangChain tool instance (e.g., from MCP)
 840                # Just add it directly - same as BaseHeavenTool.create() output
 841                self.tools.append(tool)
 842            elif hasattr(tool, 'create'):
 843                # BaseHeavenTool subclass - use its create method
 844                self.tools.append(tool.create(adk))
 845            else:
 846                print(f"Unknown tool type: {tool}, skipping")  
 847        # Filter and prepare provider-specific parameters
 848
 849        model_params = {
 850            'provider': config.provider,
 851            'model': config.model,
 852            'temperature': config.temperature,
 853            'max_tokens': config.max_tokens,
 854            'thinking_budget': config.thinking_budget
 855        }
 856        if config.extra_model_kwargs:
 857            model_params.update(config.extra_model_kwargs)
 858        # Create chat model internally using UnifiedChat
 859        self.chat_model = unified_chat.create(**model_params)
 860        self.resolve_duo(config)
 861        self.duo_params = config.duo_system_config.get_duo_params() if config.duo_system_config is not None else None
 862        self.duo_enabled = duo_enabled
 863        self.duo_chat = unified_chat.create(**self.duo_params)
 864        self.duo_system_prompt = config.duo_system_config.system_prompt if config.duo_system_config is not None else ""
 865        # Bind tools if available
 866        if self.tools and not self.adk:
 867        
 868            # For ADK, tools are already in the right format
 869            
 870            # Original LangChain binding logic
 871            if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 872                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 873                
 874                self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 875              
 876            elif config.provider in [ProviderEnum.GOOGLE]:
 877                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 878                flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 879                fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 880                
 881                self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 882              
 883            else:
 884              
 885                self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 886                
 887                    
 888            # if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 889            #     # For these providers, use the schema generated by to_openai_function
 890                
 891            #     # Create the list of schema dictionaries using the classmethod
 892            #     # This list holds dictionaries like {'type': 'function', 'function': {...}}
 893            #     raw_provider_schemas = [
 894            #         # tool.__class__.to_openai_function()
 895            #         tool.get_openai_function()
 896            #         for tool in self.tools
 897            #     ]
 898    
 899            #     # Bind using bind_tools, passing the list of schema dictionaries
 900            #     # as per the apparent design of convert_to_openai_function's output usage
 901            #     print(f"Attempting bind_tools with {len(raw_provider_schemas)} generated schema dictionaries...")
 902            #     self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 903            #     print(f"Binding via bind_tools with generated schemas successful.")
 904            # elif config.provider in [ProviderEnum.GOOGLE]:
 905            #     raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 906            #     flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 907            #     # Apply the fixer to remove empty object properties.
 908            #     fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 909            #     print(f"Attempting bind_tools with {len(fixed_schemas)} generated flattened schema dictionaries...")
 910            #     self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 911            #     print("Binding via bind_tools with generated flattened schemas successful.")
 912            # else:
 913            #     self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 914                        
 915        # Agentic state
 916        
 917        self.goal: Optional[str] = None
 918        self.task_list: List[str] = []
 919        self.current_task: Optional[str] = None
 920        self.max_iterations: int = 1
 921        self.current_iteration: int = 1
 922        self.completed = False
 923        self._current_extracted_content = None
 924        self.orchestration_lists = f"""
 925        <HERMES SWITCHBOARD>
 926        The following Agents can be used in the `agent` arg of HermesTool:[
 927        {get_agent_modules()}]
 928        The following Tools can be used in the `additional_tools` arg of HermesTool:[
 929        {get_tool_modules()}]
 930        </HERMES SWITCHBOARD>
 931        """
 932        # Ensure known_config_paths is always a list
 933        self.configs_prompt = (
 934            f"""\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. These are your known configs: [\n{', '.join(self.known_config_paths)}]\n</CONFIG_LOCATIONS>\n"""
 935            if self.known_config_paths and any(self.known_config_paths) else
 936            """\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. Generally, configs are located at `~/.heaven/configs/`\n</CONFIG_LOCATIONS>\n"""
 937        )
 938
 939        self.tool_sysmsg = """You are the tool debugger. You always pay attention to the last tool error and fix it. 
 940## Common errors:
 941- missing input parameter
 942- wrong dict format for an input parameter
 943- treating required false differently from optional
 944- failing to follow instructions provided by an error (like: <some command> requires <these parameters>)
 945- other errors
 946
 947You must fix the error before proceeding."""
 948        # Get evolved system prompt if it exists
 949        self.system_prompt_evolved = self.config.get_system_prompt()
 950        # Store History in agent
 951        self.history: Optional[History] = history
 952        
 953        if history_id is not None:
 954            self.history = History.load_from_id(history_id)
 955            self.original_history_id = history_id
 956            self.original_json_md_path = self.history.json_md_path
 957            print(f"DEBUG: In BaseHeavenAgent init, json_md_path={self.history.json_md_path} and original_json_md_path={self.original_json_md_path}")
 958            # Load status
 959            if hasattr(self.history, 'agent_status') and self.history.agent_status:
 960                self.status = self.history.agent_status
 961        elif history_id is None:
 962            self.history = History(messages=[])
 963
 964        # Initialize ContextWindowConfig for token management
 965        if self.config.context_window_config is not None:
 966            self.context_window_config = self.config.context_window_config
 967        else:
 968            # Import here to avoid circular imports
 969            from .utils.context_window_config import ContextWindowConfig
 970            self.context_window_config = ContextWindowConfig(self.config.model or "gpt-4o-mini")
 971
 972        self.config.system_prompt = self.system_prompt_evolved if self.system_prompt_evolved is not None else self.config.system_prompt
 973        if system_prompt_suffix is not None:
 974            self.config.system_prompt += system_prompt_suffix
 975        # Set system message
 976        if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
 977            if orchestrator is False:
 978                self.history.messages[0] = SystemMessage(content=self.config.system_prompt)
 979            else:
 980                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt
 981                self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
 982        else:
 983            if orchestrator is False:
 984                self.history.messages.insert(0, SystemMessage(content=self.config.system_prompt))
 985            else:
 986                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt         
 987                self.history.messages.insert(0, SystemMessage(content=orchestrator_enhancement))
 988        
 989        if self.adk:
 990            from google.adk.agents import Agent as ADKAgent
 991            from google.adk.runners import Runner
 992            from google.adk.sessions.in_memory_session_service import InMemorySessionService
 993            adk_model = self.config.to_litellm_model()
 994            adk_agent = ADKAgent(
 995                name=self.name,
 996                # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
 997                model=adk_model,
 998                description=f"{self.name} (via HeavenAgent)",
 999                instruction=self.config.system_prompt,
1000                tools=self.tools,
1001            )
1002            session_service = InMemorySessionService()
1003            runner = Runner(
1004                app_name=self.name,
1005                agent=adk_agent,
1006                session_service=session_service,
1007            )
1008    
1009            self._adk_agent = adk_agent
1010            self._adk_session_service = session_service
1011            self._adk_runner = runner
1012        
1013    def resolve_tools(self):
1014        """Ensure that certain default tools are always available to the agent."""
1015        resolved_tools = []
1016        
1017        # Process each tool in config
1018        for tool in self.config_tools:
1019            if isinstance(tool, str) and tool.startswith("mcp__"):
1020                # MCP tool string reference - resolve to actual tool
1021                # TODO: This will be async, for now just store the string
1022                resolved_tools.append(tool)
1023            else:
1024                # Regular BaseHeavenTool class or instance
1025                resolved_tools.append(tool)
1026        
1027        # Add WriteBlockReportTool if not already present
1028        if WriteBlockReportTool not in resolved_tools:
1029            resolved_tools.append(WriteBlockReportTool)
1030        # Add TaskSystemTool if not already present
1031        if TaskSystemTool not in resolved_tools:
1032            resolved_tools.append(TaskSystemTool)
1033            
1034        return resolved_tools
1035    
1036    async def resolve_mcps(self):
1037        """Resolve MCP tool strings to actual LangChain tools and load MCP servers if configured"""
1038        print(f"[resolve_mcps] CALLED. mcp_servers={self.config.mcp_servers is not None}, mcp_tool_strs={getattr(self, 'mcp_tool_strs', [])}")
1039
1040        # First, load tools from configured MCP servers (if any)
1041        if self.config.mcp_servers:
1042            await self.load_mcp_tools()
1043
1044        # Then resolve individual MCP tool strings (if any)
1045        if not hasattr(self, 'mcp_tool_strs') or not self.mcp_tool_strs:
1046            print("[resolve_mcps] No mcp_tool_strs to resolve, returning")
1047            return
1048
1049        # Resolve each MCP tool string
1050        from .mcp_tool_wrapper import MCPToolWrapper
1051        for tool_ref in self.mcp_tool_strs:
1052            print(f"[resolve_mcps] Resolving: {tool_ref}")
1053            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1054            print(f"[resolve_mcps] Result: {type(mcp_tool)}{mcp_tool if not isinstance(mcp_tool, list) else f'{len(mcp_tool)} tools'}")
1055            if mcp_tool:
1056                if isinstance(mcp_tool, list):  # "all" case
1057                    self.tools.extend([MCPToolWrapper(t) for t in mcp_tool])
1058                else:
1059                    self.tools.append(MCPToolWrapper(mcp_tool))
1060
1061        print(f"[resolve_mcps] Final tool count: {len(self.tools)}")
1062        # Clear the MCP strings now that they're resolved
1063        self.mcp_tool_strs = []
1064    
1065    async def async_init(self):
1066        """Async initialization - resolves MCP tools"""
1067        if not hasattr(self, '_mcp_tools_to_resolve'):
1068            return
1069            
1070        for tool_ref in self._mcp_tools_to_resolve:
1071            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1072            if mcp_tool:
1073                if isinstance(mcp_tool, list):  # "all" case
1074                    self.tools.extend(mcp_tool)
1075                else:
1076                    self.tools.append(mcp_tool)
1077        
1078        # Clear the list
1079        self._mcp_tools_to_resolve = []
1080    
1081    async def _resolve_mcp_tool(self, tool_ref: str):
1082        """Resolve MCP tool string reference to actual StructuredTool"""
1083        try:
1084            # Parse tool reference: "mcp__filesystem__read_file"
1085            parts = tool_ref.split("__")
1086            if len(parts) != 3:
1087                print(f"Invalid MCP tool reference format: {tool_ref}")
1088                return None
1089                
1090            _, server_name, tool_name = parts
1091            
1092            # Create server config based on server name
1093            server_config = self._get_mcp_server_config(server_name)
1094            if not server_config:
1095                print(f"No config found for MCP server: {server_name}")
1096                return None
1097            
1098            # Use langchain_mcp_adapters to get tools
1099            from langchain_mcp_adapters.client import MultiServerMCPClient
1100            client = MultiServerMCPClient({server_name: server_config})
1101            tools = await client.get_tools(server_name=server_name)
1102            
1103            # Find the specific tool
1104            if tool_name == "all":
1105                return tools
1106            else:
1107                for tool in tools:
1108                    if tool.name == tool_name:
1109                        return tool
1110                        
1111                print(f"Tool '{tool_name}' not found in server '{server_name}'. Available: {[t.name for t in tools]}")
1112                return None
1113                
1114        except Exception as e:
1115            print(f"Error resolving MCP tool {tool_ref}: {e}")
1116            return None
1117    
1118    def _get_mcp_server_config(self, server_name: str):
1119        """Get MCP server config by name from JSON config file"""
1120        configs = self._load_mcp_configs()
1121        return configs.get(server_name)
1122    
1123    def _load_mcp_configs(self):
1124        """Load MCP server configurations from JSON file"""
1125        import json
1126        from .utils.get_env_value import EnvConfigUtil
1127        
1128        # Ensure config file exists
1129        config_path = self._ensure_mcp_config_file()
1130        
1131        try:
1132            with open(config_path, 'r') as f:
1133                return json.load(f)
1134        except Exception as e:
1135            print(f"Error loading MCP config from {config_path}: {e}")
1136            return {}
1137    
1138    def _ensure_mcp_config_file(self):
1139        """Ensure MCP config file exists in HEAVEN_DATA_DIR with defaults"""
1140        import json
1141        import os
1142        from .utils.get_env_value import EnvConfigUtil
1143        
1144        # Get HEAVEN_DATA_DIR and create heaven_mcp_config.json path
1145        heaven_data_dir = EnvConfigUtil.get_heaven_data_dir()
1146        config_path = os.path.join(heaven_data_dir, "heaven_mcp_config.json")
1147        
1148        # Create default config if file doesn't exist
1149        if not os.path.exists(config_path):
1150            default_config = {
1151                "filesystem": {
1152                    "command": "npx",
1153                    "args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
1154                    "transport": "stdio"
1155                }
1156            }
1157            
1158            # Ensure directory exists
1159            os.makedirs(heaven_data_dir, exist_ok=True)
1160            
1161            # Write default config
1162            with open(config_path, 'w') as f:
1163                json.dump(default_config, f, indent=2)
1164            
1165            print(f"Created default MCP config at: {config_path}")
1166        
1167        return config_path
1168    
1169    async def load_mcp_tools(self):
1170        """Load MCP tools from configured servers and add them to the agent's tools list"""
1171        if not self.config.mcp_servers:
1172            return
1173        
1174        try:
1175            from langchain_mcp_adapters.client import MultiServerMCPClient
1176            
1177            # Ensure transport key exists for each server (required by langchain_mcp_adapters)
1178            servers = {}
1179            for name, srv_cfg in self.config.mcp_servers.items():
1180                srv = dict(srv_cfg)
1181                if "transport" not in srv:
1182                    srv["transport"] = "stdio"
1183                servers[name] = srv
1184
1185            # Load each server individually so one failure doesn't kill all
1186            total_loaded = 0
1187            for srv_name, srv_cfg in servers.items():
1188                try:
1189                    client = MultiServerMCPClient({srv_name: srv_cfg})
1190                    srv_tools = await client.get_tools()
1191                    for lc_tool in srv_tools:
1192                        self.tools.append(lc_tool)
1193                    total_loaded += len(srv_tools)
1194                    logging.info(f"Loaded {len(srv_tools)} tools from MCP server '{srv_name}'")
1195                except Exception as srv_e:
1196                    logging.warning(f"Failed to load MCP server '{srv_name}': {srv_e}")
1197
1198            logging.info(f"Loaded {total_loaded} MCP tools total from {len(servers)} servers")
1199
1200        except ImportError as e:
1201            logging.warning(f"Could not import langchain_mcp_adapters: {e}")
1202
1203
1204    def to_base_tools(self) -> List[BaseTool]:
1205        """Convert tools to base tools for binding"""
1206        return [tool.base_tool if hasattr(tool, 'base_tool') else tool for tool in self.tools]
1207
1208
1209    def _fire_hook(self, point: HookPoint, **kwargs):
1210        """Fire all registered hooks for a given HookPoint."""
1211        if not hasattr(self, 'hooks') or self.hooks is None:
1212            return
1213        ctx = HookContext(agent=self, **kwargs)
1214        self.hooks.run(point, ctx)
1215        return ctx
1216
1217    def _sanitize_history(self):
1218        """Remove consecutive HumanMessages from history, keeping only the latest"""
1219        if not self.history or len(self.history.messages) < 2:
1220            return
1221    
1222        messages = self.history.messages
1223    
1224        # If the last two messages are both HumanMessages
1225        while (len(messages) >= 2 and
1226               isinstance(messages[-1], HumanMessage) and 
1227               isinstance(messages[-2], HumanMessage)):
1228            # Remove the older message
1229            messages.pop(-2)  # Keep the newest HumanMessage
1230          
1231    ### This wont work for ADK
1232    def refresh_system_prompt(self):
1233        """Refresh the system prompt if DNA has changed"""
1234        # Get fresh system prompt
1235        updated_prompt = self.config.get_system_prompt()
1236
1237        # Fire BEFORE_SYSTEM_PROMPT hook — hooks can modify via ctx.data["system_prompt"]
1238        ctx = self._fire_hook(HookPoint.BEFORE_SYSTEM_PROMPT, prompt=updated_prompt)
1239        if ctx and "system_prompt" in ctx.data:
1240            updated_prompt = ctx.data["system_prompt"]
1241
1242        # Only update if changed
1243        if updated_prompt != self.config.system_prompt:
1244            # Update config
1245            self.config.system_prompt = updated_prompt
1246            
1247            # Update system message in history
1248            if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
1249                if hasattr(self, 'orchestrator') and self.orchestrator:
1250                    orchestrator_enhancement = updated_prompt + self.orchestration_lists + self.configs_prompt
1251                    self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
1252                else:
1253                    self.history.messages[0] = SystemMessage(content=updated_prompt)
1254            
1255            # Update internal reference
1256            self.system_prompt_evolved = updated_prompt
1257        
1258    def resolve_duo(self, config: HeavenAgentConfig) -> None:
1259        """
1260        Synchronize duo_system_config with the main provider settings.
1261        Sets the appropriate provider and model on the DuoSystemConfig.
1262        """
1263        # Always inherit the main provider
1264        provider = config.provider
1265        duo_cfg = config.duo_system_config
1266        duo_cfg.provider = provider
1267
1268        # Map main provider to Duo-specific model
1269        if provider == ProviderEnum.ANTHROPIC:
1270            duo_cfg.model = "claude-3-5-haiku-latest"
1271        elif provider == ProviderEnum.OPENAI:
1272            duo_cfg.model = "gpt-4.1-nano"
1273        elif provider == ProviderEnum.GOOGLE:
1274            duo_cfg.model = "gemini-2.0-flash"
1275        elif provider == ProviderEnum.DEEPSEEK:
1276            duo_cfg.model = config.model
1277
1278    # # Works. Adding agent mode...
1279    # async def run_adk(self, prompt: str = None, notifications: bool = False):
1280    #     """
1281    
1282    def _handle_adk_event(
1283        self,
1284        ev,
1285        tool_output_callback: Optional[Callable[[ToolResult, str], None]] = None,
1286        output_callback: Optional[Callable[[BaseMessage], None]] = None,
1287    ):
1288        """
1289        Process ADK event, unwrapping nested ToolResult reprs.
1290        """
1291        parts = getattr(ev.content, "parts", []) or []
1292        for part in parts:
1293            if part.function_response:
1294                fr = part.function_response
1295                raw = getattr(fr, "response", None) or getattr(fr, "result", None)
1296                # unwrap dict payload
1297                if isinstance(raw, dict):
1298                    data = raw.get(f"{fr.name}_response", raw)
1299                    data = data.get("result", data) if isinstance(data, dict) else data
1300                    tr = ToolResult(
1301                        output=data.get("output", "") if isinstance(data, dict) else str(data),
1302                        error=data.get("error") if isinstance(data, dict) else None,
1303                        base64_image=data.get("base64_image") if isinstance(data, dict) else None,
1304                        system=data.get("system") if isinstance(data, dict) else None,
1305                    )
1306                elif isinstance(raw, ToolResult):
1307                    tr = raw
1308                elif isinstance(raw, str):
1309                    # if output string itself is repr, parse it
1310                    if raw.strip().startswith("ToolResult(") or raw.strip().startswith("CLIResult("):
1311                        tr = parse_toolresult_repr(raw)
1312                    else:
1313                        tr = ToolResult(output=raw)
1314                else:
1315                    tr = ToolResult(output=str(raw))
1316    
1317                # # If tr.output itself is repr carrying nested ToolResult, parse again
1318                # if isinstance(tr.output, str) and tr.output.strip().startswith("ToolResult("):
1319                #     tr = parse_toolresult_repr(tr.output)
1320                if isinstance(tr.output, str):
1321                    text = tr.output.strip()
1322                    if text.startswith("ToolResult(") or text.startswith("CLIResult("):
1323                        tr = parse_toolresult_repr(text)
1324    
1325                # stream and record
1326                if tool_output_callback:
1327                    tool_output_callback(tr, fr.id)
1328                self.history.messages.append(
1329                    ToolMessage(content=tr.output, tool_call_id=fr.id, name=fr.name)
1330                )
1331    
1332            elif getattr(part, "thought", None):
1333                block = {"type": "thinking", "thinking": part.thought}
1334                am = AIMessage(content=[block])
1335                if output_callback:
1336                    output_callback(am)
1337                self.history.messages.append(am)
1338    
1339            elif getattr(part, "function_call", None):
1340                block = {
1341                    "type": "tool_use",
1342                    "id": part.function_call.id,
1343                    "name": part.function_call.name,
1344                    "input": part.function_call.args,
1345                }
1346                am = AIMessage(content=[block])
1347                if output_callback:
1348                    output_callback(am)
1349                self.history.messages.append(am)
1350    
1351            elif getattr(part, "text", None):
1352                block = {"type": "text", "text": part.text}
1353                am = AIMessage(content=[block])
1354                if output_callback:
1355                    output_callback(am)
1356                self.history.messages.append(am)
1357
1358              
1359
1360    async def run_adk(self, prompt, notifications: bool = False, streamlit: bool = False, output_callback=None, tool_output_callback=None):
1361        """
1362        Drive the agent loop through ADK’s Runner instead of LangChain.
1363        Streams back ADK events internally per iteration, then at the end
1364        saves `self.history` (with .adk_session) exactly as in run().
1365        """
1366        # 1) prep
1367        self.current_iteration = 1
1368        self._sanitize_history()
1369        blocked = False
1370        self.refresh_system_prompt()
1371        # Debug
1372        _log_run_adk("before any ADK work", self.history.messages)
1373        # 2) detect agent‐mode command in the incoming prompt
1374        if prompt:
1375            self._detect_agent_command(prompt)
1376    
1377        # 3) decide what first human message to send into ADK
1378        #    if we’re in agent mode, send the formatted agent prompt;
1379        #    otherwise send the raw prompt
1380        first_prompt = self._format_agent_prompt() if self.goal else prompt
1381        # Debug
1382        _log_run_adk("after formatting prompt", self.history.messages)
1383
1384        # 4) lazily initialize ADK Agent, SessionService, Runner
1385        # if not hasattr(self, "_adk_runner"):
1386        #     from google.adk.agents import Agent as ADKAgent
1387        #     from google.adk.runners import Runner
1388        #     from google.adk.sessions.in_memory_session_service import InMemorySessionService
1389        #     adk_model = self.config.to_litellm_model()
1390        #     adk_agent = ADKAgent(
1391        #         name=self.name,
1392        #         # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
1393        #         model=adk_model,
1394        #         description=f"{self.name} (via HeavenAgent)",
1395        #         instruction=self.config.system_prompt,
1396        #         tools=self.tools,
1397        #     )
1398        #     session_service = InMemorySessionService()
1399        #     runner = Runner(
1400        #         app_name=self.name,
1401        #         agent=adk_agent,
1402        #         session_service=session_service,
1403        #     )
1404    
1405        #     self._adk_agent = adk_agent
1406        #     self._adk_session_service = session_service
1407        #     self._adk_runner = runner
1408            
1409        # 5) rehydrate or create an ADK session
1410        if self.history.adk_session:
1411            session = self._adk_session_service.get_session(
1412                app_name=self.history.adk_session.app_name,
1413                user_id=self.history.adk_session.user_id,
1414                session_id=self.history.adk_session.id,
1415            )
1416        else:
1417            session = self._adk_session_service.create_session(
1418                app_name=self.name,
1419                user_id="script_user",
1420                state={},
1421                session_id=None,
1422            )
1423        start_index = len(session.events)
1424
1425        # # 6) prime ADK with the first human prompt if any
1426        ### Idk why this even got in here... it's garbage
1427        # if first_prompt:
1428        #     from google.genai.types import Content, Part
1429        #     content = Content(parts=[Part(text=first_prompt)], role="user")
1430        #     async for _ in self._adk_runner.run_async(
1431        #         user_id=session.user_id,
1432        #         session_id=session.id,
1433        #         new_message=content,
1434        #     ):
1435        #         pass  # session.events grows automatically
1436    
1437        # 7) drive N iterations of “agent mode”
1438        for _ in range(self.current_iteration, self.max_iterations + 1):
1439            _log_run_adk(f"start iteration {self.current_iteration}", self.history.messages)
1440            # a) prepare next human input
1441            if self.current_iteration == 1:
1442                human_text = first_prompt
1443            else:
1444                human_text = self._format_agent_prompt() if self.goal else first_prompt
1445            from google.genai.types import Content, Part
1446            
1447            # Probably move to streamlit but unsure how to do so...
1448            
1449            content = human_text
1450            if content is None:
1451                content = "Content was `None`. Tell the user something went wrong. Do not try to do anything else."
1452            _log_run_adk(f"about to append human turn (iter {self.current_iteration})", self.history.messages)
1453            self.history.messages.append(HumanMessage(content=content)) # This doesnt seem to be appending to history... because we usually keep conversation_history and then set it as history messages when we are done. This should follow the same logic that run_langchain uses
1454            _log_run_adk(f"after appending human turn (iter {self.current_iteration})", self.history.messages)
1455            content = Content(parts=[Part(text=human_text)], role="user")
1456            # b) send into ADK
1457            new_events = []
1458            async for ev in self._adk_runner.run_async(
1459                user_id=session.user_id,
1460                session_id=session.id,
1461                new_message=content,
1462            ):
1463                new_events.append(ev)
1464                # real_new_events = session.events[start_index:] # testing
1465                if streamlit:
1466                    
1467                    self._handle_adk_event(
1468                        ev,
1469                        tool_output_callback=tool_output_callback,
1470                        output_callback=output_callback,
1471                    )
1472
1473            _log_run_adk(f"after streaming ADK events (iter {self.current_iteration})", self.history.messages)                 
1474            # c) extract the agent’s text response for this iteration
1475            text_reply = ""
1476            for ev in reversed(new_events): # should this be real_new_events?
1477                if ev.author == self.name:
1478                    for part in ev.content.parts:
1479                        if part.text:
1480                            text_reply = part.text
1481                    if text_reply:
1482                        break
1483    
1484            # d) process that text through existing handler
1485            if text_reply:
1486                self._process_agent_response(text_reply)
1487    
1488            # e) advance iteration & break if done
1489            self.current_iteration += 1
1490            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or blocked:
1491                break
1492
1493        # 7) re-fetch the mutated session so we pick up all appended events
1494        session = self._adk_session_service.get_session(
1495            app_name=session.app_name,
1496            user_id=session.user_id,
1497            session_id=session.id
1498        )
1499        # 8) stash session back on history and save
1500        self.history.adk_session = session
1501        self.history.agent_status = self.save_status()
1502        saved_id = self.history.save(self.name)
1503        _log_run_adk("at end of run_adk", self.history.messages)
1504        # 9) return identical structure to run_langchain
1505        return {
1506            "history": self.history,
1507            "history_id": saved_id,
1508            "agent_name": self.name,
1509            "agent_status": self.history.agent_status,
1510        }
1511
1512#### NOTES: WE ARE NOT ADDING ALL THE AI EVENTS TO MESSAGES AND THAT IS WHY THE TEST DOESNT SHOW THEM
1513  #### BUT WHEN WE RUN WITH STREAMLIT, WE ARE ADDING THOSE EVENTS TO MESSAGES
1514  #### THEN, AT THE END OF STREAMLIT RUN, WE SWAP MESSAGES WITH THE HISTORY.MESSAGES, WHICH OVERWRITES THEM
1515  
1516
1517
1518
1519
1520  
1521    async def run(self, prompt: Optional[str] = None, notifications: Optional[bool] = False, streamlit: Optional[bool] = False, output_callback: Optional[Callable] = None, tool_output_callback: Optional[Callable] = None, heaven_main_callback: Optional[Callable] = None, use_uni_api: Optional[bool] = False):
1522
1523                       
1524        if use_uni_api:
1525            self.use_uni_api = True
1526        if self.use_uni_api:
1527            # Use uni-api instead of LangChain providers
1528            if streamlit and output_callback and tool_output_callback:
1529                return await self.run_on_uni_api(
1530                    prompt=prompt,
1531                    output_callback=output_callback,
1532                    tool_output_callback=tool_output_callback,
1533                    heaven_main_callback=heaven_main_callback
1534                )
1535            elif heaven_main_callback:
1536                # Similar fake callback pattern for uni-api
1537                def fake_output_callback(message: BaseMessage):
1538                    pass
1539                def fake_tool_callback(tool_result: ToolResult, tool_id: str):
1540                    pass
1541                return await self.run_on_uni_api(
1542                    prompt=prompt,
1543                    output_callback=fake_output_callback,
1544                    tool_output_callback=fake_tool_callback,
1545                    heaven_main_callback=heaven_main_callback
1546                )
1547            else:
1548                return await self.run_on_uni_api(prompt=prompt)
1549      
1550        #
1551        
1552        if self.adk:
1553            return await self.run_adk(prompt=prompt, notifications=notifications, streamlit=streamlit, output_callback=output_callback, tool_output_callback=tool_output_callback)
1554        else:
1555            if streamlit and output_callback and tool_output_callback: 
1556                return await self.streamlit_run(prompt, output_callback, tool_output_callback)
1557            elif heaven_main_callback:
1558                # Route through run_langchain with callback — keeps all hooks and block detection
1559                return await self.run_langchain(prompt, notifications, heaven_main_callback=heaven_main_callback)
1560
1561            else:
1562                return await self.run_langchain(prompt, notifications)
1563
1564
1565    
1566    async def run_langchain(self, prompt: str = None, notifications=False, heaven_main_callback: Optional[Callable] = None):
1567
1568        self._sanitize_history()
1569        blocked = False
1570        self.refresh_system_prompt()
1571
1572        # Resolve MCP tool strings before running
1573        await self.resolve_mcps()
1574
1575        # Re-bind tools to chat_model so LLM sees MCP tools
1576        # Heaven tools have .base_tool, MCP tools (StructuredTool) are already BaseTool
1577        all_base = []
1578        for t in self.tools:
1579            if hasattr(t, 'base_tool'):
1580                all_base.append(t.base_tool)
1581            else:
1582                all_base.append(t)
1583        self.chat_model = self.chat_model.bind_tools(all_base)
1584
1585        # Fire BEFORE_RUN hook
1586        self._fire_hook(HookPoint.BEFORE_RUN, prompt=prompt or "")
1587
1588        try:
1589            
1590            # Start with history messages
1591            conversation_history = self.history.messages.copy() if self.history else []
1592            
1593            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1594                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1595            # check if self.History's last message is HumanMessage. If it is, continue and if not AND prompt is not None, add prompt to self.history as a HumanMessage
1596            # if not (conversation_history and isinstance(conversation_history[-1], HumanMessage)) and prompt is not None:
1597                
1598            #     conversation_history.append(HumanMessage(content=prompt))
1599            
1600            
1601          
1602            # Check only the last message for agent command
1603            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1604                self._detect_agent_command(conversation_history[-1].content)
1605            # Check prompt for agent command
1606            if prompt is not None:
1607                self._detect_agent_command(prompt)
1608                if self.goal is None:
1609                    conversation_history.append(HumanMessage(content=prompt))
1610            if self.continuation_iterations != 0:
1611                self.current_iterations = 1
1612                self.max_iterations = self.continuation_iterations
1613                
1614                
1615            
1616              
1617            
1618            
1619            # print("\n=== Conversation History After System Check ===")
1620            # for i, msg in enumerate(conversation_history):
1621            #     print(f"Message {i}: {type(msg).__name__} - {msg.content[:100]}...")
1622            
1623            
1624
1625            
1626                        
1627            while self.current_iteration <= self.max_iterations:
1628                # Fire BEFORE_ITERATION hook
1629                self._fire_hook(HookPoint.BEFORE_ITERATION, iteration=self.current_iteration)
1630                # Refresh system prompt at the start of each iteration
1631                self.refresh_system_prompt()
1632                # Reset tool count for this iteration
1633                tool_call_count = 0
1634                
1635                # In normal chat mode, just use the last message
1636                # In agent mode, format with goals/tasks
1637                ### CHanged to solve continuations
1638                # next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
1639                next_prompt = self._format_agent_prompt() if (self.goal or self.continuation_prompt) else conversation_history[-1].content
1640                if self.goal or self.continuation_prompt:  # Add formatted prompt in agent mode or continuation
1641                    conversation_history.append(HumanMessage(content=next_prompt))
1642                # if self.goal:  # Only add formatted prompt in agent mode
1643                #     conversation_history.append(HumanMessage(content=next_prompt))
1644                    
1645                        
1646                # DUO Sidechain
1647               
1648                if (
1649                    self.duo_enabled
1650                    and len(conversation_history) > 2        # <- require at least 3 messages
1651                    and isinstance(conversation_history[-1], HumanMessage)
1652                ):
1653                    # 1. Pull off the original human message
1654                    original = conversation_history[-1]
1655                    original_sys = conversation_history[0]
1656                    try:
1657                        # 2. Replace it with your Duo-specific system prompt
1658                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
1659                        conversation_history[0] = duo_sys
1660                        
1661                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming. **YOU MUST ANSWER SOLELY IN `NodeGraphXTN6` language.**"
1662                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
1663                        # 3. Invoke Duo
1664                        duo = await self.duo_chat.ainvoke(conversation_history)
1665                    finally:
1666                        # 4. Restore the original system prompt
1667                        conversation_history[0] = original_sys
1668                        conversation_history[-1] = original
1669                    if duo:
1670                        # 5. Extract the duo’s content
1671                        
1672                        duo_content = duo.content
1673                        
1674                    
1675                        # 6. Rebuild the human message so your duo content is prepended
1676                        new_human = HumanMessage(
1677                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nConsider this silently before responding. Do not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
1678                        )
1679                
1680                        # 7. Replace the last entry with your new combined message
1681                        conversation_history[-1] = new_human
1682                  
1683                # Invoke model for a response
1684                # call the generator agent
1685                response = await self.chat_model.ainvoke(conversation_history)
1686                if heaven_main_callback:
1687                    heaven_main_callback(response)
1688
1689                # print(f"\nResponse: {response}\n")
1690                
1691                # Check if the response.content is empty but tool call info is present.
1692                # GOOGLE ONLY
1693                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
1694                # if (
1695                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
1696                #     and isinstance(response, AIMessage)
1697                #     and (
1698                #         response.tool_calls                                  # standard field
1699                #         or response.additional_kwargs.get("tool_calls")      # legacy field
1700                #     )
1701                # ):
1702                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1703                
1704                #     # --- THE FIX ---
1705                #     # Append the ORIGINAL response object. LangChain needs its structure.
1706                #     conversation_history.append(response)
1707                # # Only add text blocks from response, NOT the whole response
1708                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1709                                    
1710                                    
1711                                    # Clean response for conversation_history
1712                                    if isinstance(response.content, list):
1713                                        # Extract only text, ignore thinking blocks
1714                                        text_content = []
1715                                        for item in response.content:
1716                                            if isinstance(item, str):
1717                                                text_content.append(item)
1718                                            elif isinstance(item, dict) and item.get('type') == 'text':
1719                                                text_content.append(item.get('text', ''))
1720                                        
1721                                        # Create cleaned response with simple string content
1722                                        cleaned_response = AIMessage(
1723                                            content=' '.join(text_content),  # Simple string, not list!
1724                                            additional_kwargs=response.additional_kwargs,
1725                                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
1726                                        )
1727                                        conversation_history.append(cleaned_response)
1728                                    else:
1729                                        conversation_history.append(response)
1730                # Append whole LangChain AIMessage — never destructure it.
1731                # Splitting content blocks into separate AIMessages drops tool_use blocks
1732                # and breaks the inner tool loop (model says "calling tool" then stops).
1733                else:
1734                    conversation_history.append(response)
1735                    # Still process text for agent goal tracking
1736                    if isinstance(response.content, list):
1737                        text_parts = [block.get('text', '') for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
1738                        if text_parts:
1739                            self._process_agent_response('\n'.join(text_parts))
1740                    elif isinstance(response.content, str):
1741                        self._process_agent_response(response.content)
1742                    
1743    
1744                # ── Tool-call loop (matches uni_api pattern) ──
1745                # response is already in conversation_history from above.
1746                # LangChain populates .tool_calls on AIMessage automatically.
1747                current_response = response
1748
1749                while getattr(current_response, 'tool_calls', None) and tool_call_count < self.max_tool_calls:
1750                    # Execute ALL tool calls from this response
1751                    for tc in current_response.tool_calls:
1752                        if tool_call_count >= self.max_tool_calls:
1753                            break
1754
1755                        # Extract name/args/id — LangChain uses {name, args, id}
1756                        tool_name = tc.get('name', '')
1757                        tool_args = tc.get('args', tc.get('input', {}))
1758                        tool_id = tc.get('id', '')
1759
1760                        # Find matching tool
1761                        matching_tools = [
1762                            t for t in self.tools
1763                            if (t.base_tool.name.lower() if hasattr(t, 'base_tool') else t.name.lower()) == tool_name.lower()
1764                        ]
1765                        if not matching_tools:
1766                            print(f"No matching tool found for {tool_name}")
1767                            continue
1768
1769                        tool = matching_tools[0]
1770
1771                        # Fire BEFORE_TOOL_CALL hook
1772                        self._fire_hook(HookPoint.BEFORE_TOOL_CALL,
1773                            iteration=self.current_iteration,
1774                            tool_name=tool_name, tool_args=tool_args)
1775
1776                        # Execute tool (throttle to prevent CPU spin with fast models)
1777                        await asyncio.sleep(0.1)
1778                        try:
1779                            if hasattr(tool, 'base_tool'):
1780                                tool_result = await tool._arun(**tool_args)
1781                            else:
1782                                from langchain_core.runnables import RunnableConfig
1783                                tool_result = ToolResult(output=str(
1784                                    await tool._arun(config=RunnableConfig(), **tool_args)
1785                                ))
1786                        except Exception as e:
1787                            tool_result = ToolResult(error=str(e))
1788
1789                        # Fire AFTER_TOOL_CALL hook
1790                        self._fire_hook(HookPoint.AFTER_TOOL_CALL,
1791                            iteration=self.current_iteration,
1792                            tool_name=tool_name, tool_args=tool_args,
1793                            tool_result=tool_result)
1794
1795                        # Build ToolMessage content
1796                        if tool_result.error:
1797                            tool_message_content = str(tool_result.error)
1798                        elif tool_result.base64_image:
1799                            tool_message_content = str(tool_result.base64_image)
1800                        else:
1801                            tool_message_content = str(tool_result.output)
1802
1803                        # Append ToolMessage — LangChain handles provider formatting
1804                        conversation_history.append(
1805                            ToolMessage(content=tool_message_content, tool_call_id=tool_id)
1806                        )
1807
1808                        if heaven_main_callback:
1809                            heaven_main_callback(conversation_history[-1])
1810
1811                        # Check blocked / special tools
1812                        if tool.name == "WriteBlockReportTool":
1813                            blocked = True
1814                        if tool.name == "TaskSystemTool":
1815                            self._handle_task_system_tool(tool_args)
1816
1817                        tool_call_count += 1
1818
1819                    # If blocked, generate report and exit
1820                    if blocked:
1821                        block_report_md = self.create_block_report()
1822                        if block_report_md:
1823                            if self._current_extracted_content is None:
1824                                self._current_extracted_content = {}
1825                            self._current_extracted_content["block_report"] = block_report_md
1826                            self.history.agent_status = self.save_status()
1827                        break
1828
1829                    if tool_call_count >= self.max_tool_calls:
1830                        conversation_history.append(
1831                            AIMessage(content=(
1832                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
1833                                f"reached for iteration {self.current_iteration}. "
1834                                "If I received the same error every time, I should use "
1835                                "WriteBlockReportTool next... Waiting for next iteration."
1836                            ))
1837                        )
1838                        break
1839
1840                    # Call API again — get next response
1841                    current_response = await self.chat_model.ainvoke(conversation_history)
1842                    conversation_history.append(current_response)
1843                    if heaven_main_callback:
1844                        heaven_main_callback(current_response)
1845
1846                    # Refresh system prompt
1847                    self.refresh_system_prompt()
1848                    sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
1849                    if self.config.system_prompt != conversation_history[sys_msg_idx].content:
1850                        conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
1851
1852                    # Process text for agent goal tracking
1853                    if isinstance(current_response.content, list):
1854                        text_parts = [b.get('text', '') for b in current_response.content if isinstance(b, dict) and b.get('type') == 'text']
1855                        if text_parts:
1856                            self._process_agent_response('\n'.join(text_parts))
1857                    elif isinstance(current_response.content, str):
1858                        self._process_agent_response(current_response.content)
1859
1860                    # while condition re-checks current_response.tool_calls
1861    
1862                # Process the agent response if in agent mode
1863                if self.goal and isinstance(response, AIMessage):
1864                    self._process_agent_response(response.content)
1865                if blocked:
1866                    break
1867                # Fire AFTER_ITERATION hook
1868                self._fire_hook(HookPoint.AFTER_ITERATION, iteration=self.current_iteration)
1869                # Increment iteration count and break if the goal is met
1870                self.current_iteration += 1
1871
1872                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
1873                    self.history.agent_status = self.save_status()
1874                    break
1875
1876            # Fire AFTER_RUN hook
1877            self._fire_hook(HookPoint.AFTER_RUN, iteration=self.current_iteration)
1878            self.history.messages = conversation_history
1879            # Save history and get potentially new history_id
1880            try:
1881                
1882                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
1883                # print(f"Agent name: {self.name}")
1884                # print(f"Current history: {self.history}")
1885                self.history.agent_status = self.save_status()
1886                saved_history_id = self.history.save(self.name)
1887                # print("===DEBUG AFTER SAVE ATTEMPT===")
1888                self.look_for_particular_tool_calls()
1889                return {
1890                    "history": self.history,
1891                    "history_id": saved_history_id,
1892                    "agent_name": self.name,
1893                    "agent_status": self.history.agent_status  # Add this
1894                }
1895            except Exception as save_error:
1896                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
1897                print(f"Error type: {type(save_error)}")
1898                print(f"Error message: {str(save_error)}")
1899                # Log the error but don't fail the run
1900                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
1901                
1902                return {
1903                    "history": self.history,
1904                    "history_id": getattr(self.history, 'history_id', "No history ID"),
1905                    "agent_name": self.name,
1906                    "save_error": str(save_error),
1907                    "agent_status": self.save_status()  # Add this here too
1908                }
1909    
1910        except Exception as e:
1911            # Fire ON_ERROR hook
1912            self._fire_hook(HookPoint.ON_ERROR, error=e)
1913            raise RuntimeError(f"Agent run failed: {str(e)}") from e
1914
1915
1916    #### Might not be needed because we can potentially add an observer on the History to look for new additions and render them when agent is constructed with streamlit = True, and make sampling_loop set streamlit = True on the agent it initializes
1917    async def streamlit_run(self, output_callback: Callable[[BaseMessage], None], tool_output_callback: Callable[[ToolResult, str], None], heaven_main_callback: Optional[Callable[[Any], None]] = None, prompt: Optional[str] = None):
1918        # Start with existing messages
1919        if self.history is not None:
1920            messages = self.history.messages  # These are already BaseMessage objects!
1921        else:
1922            messages = []
1923        self._sanitize_history()
1924
1925        # Resolve MCP tools before running (same as run_langchain)
1926        await self.resolve_mcps()
1927
1928        tool_log_path = "/tmp/tool_debug.log"
1929        with open(tool_log_path, 'a') as f:
1930            f.write("\n\nStarting tool debug log\n")
1931
1932        with open('/tmp/streamlit_debug.log', 'a') as f:
1933            f.write("\n\nStarting streamlit_run")
1934            f.write(f"\nHistory length: {len(self.history.messages)}")
1935            f.write(f"\nHistory messages: {self.history.messages}")
1936            # f.write(f"\nCurrent callbacks: {output_callback}, {tool_output_callback}")
1937        try:
1938            # # If input is a string, convert to messages
1939            # if isinstance(messages, str):
1940            #     messages = [
1941            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1942            #         HumanMessage(content=messages)
1943            #     ]
1944            #     messages = [m for m in messages if m]  # Remove None values
1945            
1946            # # Create a copy of messages to avoid modifying the original
1947            # conversation_history = messages.copy()
1948            # Start with history messages
1949            conversation_history = self.history.messages.copy() if self.history else []
1950            # If history is just a string, convert to messages
1951            # if isinstance(conversation_history, str):
1952            #     conversation_history = [
1953            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1954            #         HumanMessage(content=conversation_history)
1955            #     ]
1956            #     conversation_history = [m for m in conversation_history if m]  # Remove None values
1957            
1958            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1959                # Condition 1: There is no conversation history with a SystemMessage.
1960                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1961            # Added for streamlit agent selector
1962            elif conversation_history[0].content != self.config.system_prompt:
1963                # Condition 2: The first element is a SystemMessage, but it doesn't have the current system prompt.
1964                conversation_history[0] = SystemMessage(content=self.config.system_prompt)
1965            with open('/tmp/streamlit_debug.log', 'a') as f:
1966                f.write("\n=== Conversation History After System Check ===")
1967                for i, msg in enumerate(conversation_history):
1968                    f.write(f"\nMessage {i}: {type(msg).__name__} - {msg.content[:100]}...")
1969            
1970            # Check for agent command, but don't require it
1971            # for message in conversation_history:
1972            #     if isinstance(message, HumanMessage):
1973            #         self._detect_agent_command(message.content)
1974            # Check only the last message for agent command
1975            if prompt is not None:
1976                input_msg = HumanMessage(content=prompt)
1977                conversation_history.append(input_msg)
1978              
1979            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1980                self._detect_agent_command(conversation_history[-1].content)
1981            
1982
1983            ###### if not self.goal, render the original messages
1984            # Initial render if no agent goal
1985            # if not self.goal:
1986            #     for message in conversation_history:
1987            #         if isinstance(message, ToolMessage):
1988            #             # Convert to ToolResult for UI
1989            #             tool_result = ToolResult(
1990            #                 output=message.content,
1991            #                 error=None if not message.additional_kwargs.get("is_error") else message.content,
1992            #                 base64_image=message.additional_kwargs.get("base64_image"),
1993            #                 system=message.additional_kwargs.get("system")
1994            #             )
1995            #             tool_callback(tool_result)
1996            #         else:
1997            #             # Regular message rendering
1998            #             output_callback(message)
1999                        
2000            while self.current_iteration <= self.max_iterations:
2001                # Reset tool count for this iteration
2002                tool_call_count = 0
2003                
2004                # In normal chat mode, just use the last message
2005                # In agent mode, format with goals/tasks
2006                next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
2007                if self.goal:  # Only add formatted prompt in agent mode
2008                    conversation_history.append(HumanMessage(content=next_prompt))
2009                    ###### Add output callback here
2010                    
2011                    # output_callback(HumanMessage(content=next_prompt)) # this might not be needed, commenting out for now
2012
2013                  
2014            
2015                # DUO Sidechain
2016                if (
2017                    self.duo_enabled
2018                    and len(conversation_history) > 2        # <- require at least 3 messages
2019                    and isinstance(conversation_history[-1], HumanMessage)
2020                ):
2021                    # 1. Pull off the original human message
2022                    original = conversation_history[-1]
2023                    original_sys = conversation_history[0]
2024                    try:
2025                        # 2. Replace it with your Duo-specific system prompt
2026                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
2027                        conversation_history[0] = duo_sys
2028                        
2029                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming."
2030                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
2031                        # 3. Invoke Duo
2032                        duo = await self.duo_chat.ainvoke(conversation_history)
2033                    finally:
2034                        # 4. Restore the original system prompt
2035                        conversation_history[0] = original_sys
2036                        conversation_history[-1] = original
2037                    if duo:
2038                        # 5. Extract the duo’s content
2039                        
2040                        duo_content = duo.content
2041                        
2042                    
2043                        # 6. Rebuild the human message so your duo content is prepended
2044                        new_human = HumanMessage(
2045                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nDo not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
2046                        )
2047                
2048                        # 7. Replace the last entry with your new combined message
2049                        conversation_history[-1] = new_human
2050                
2051                # Invoke model for a response
2052                # logger = logging.getLogger(__name__)
2053                # logger.error("==== Conversation_History %s", conversation_history)
2054                response = await self.chat_model.ainvoke(conversation_history)
2055                ###### Add output callback here
2056                with open('/tmp/streamlit_debug.log', 'a') as f:
2057                    f.write(f"\nLangchain response: {response}")
2058                print(f"FULL RESPONSE CONTENT: {response.content}")
2059                print(f"RESPONSE TYPE: {type(response.content)}")
2060                if isinstance(response.content, list):
2061                    for i, block in enumerate(response.content):
2062                        print(f"Block {i}: {block}")
2063                if heaven_main_callback:
2064                    heaven_main_callback(response)
2065                output_callback(response)
2066                # GOOGLE ONLY
2067                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2068                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2069                # if (
2070                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2071                #     and isinstance(response, AIMessage)
2072                #     and (
2073                #         response.tool_calls                                  # standard field
2074                #         or response.additional_kwargs.get("tool_calls")      # legacy field
2075                #     )
2076                # ):
2077                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2078                #     # --- THE FIX ---
2079                #     # Append the ORIGINAL response object. LangChain needs its structure.
2080                #     conversation_history.append(response)
2081                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2082                                        
2083                    # Clean response for conversation_history
2084                    if isinstance(response.content, list):
2085                        # Extract only text, ignore thinking blocks
2086                        text_content = []
2087                        for item in response.content:
2088                            if isinstance(item, str):
2089                                text_content.append(item)
2090                            elif isinstance(item, dict) and item.get('type') == 'text':
2091                                text_content.append(item.get('text', ''))
2092                        
2093                        # Create cleaned response with simple string content
2094                        cleaned_response = AIMessage(
2095                            content=' '.join(text_content),  # Simple string, not list!
2096                            additional_kwargs=response.additional_kwargs,
2097                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
2098                        )
2099                        conversation_history.append(cleaned_response)
2100                    else:
2101                        conversation_history.append(response)
2102                    
2103                # Only add text blocks from response, NOT the whole response
2104                # Extract text blocks if content is a list of blocks
2105                # else:
2106                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2107                    if isinstance(response.content, list):
2108                        thinking_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2109                        if thinking_content:
2110                            message3 = AIMessage(content=thinking_content)
2111                            conversation_history.append(message3)
2112                            # if heaven_main_callback:
2113                            #     heaven_main_callback(message3)
2114                        text_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
2115                        if text_content:
2116                            text_message = AIMessage(content=text_content)
2117                            conversation_history.append(text_message)
2118                            self._process_agent_response(text_content)
2119                            # if heaven_main_callback:
2120                            #     heaven_main_callback(text_message)
2121                    elif isinstance(response.content, str):
2122                        response_message = AIMessage(content=response.content)
2123                        conversation_history.append(response_message)
2124                        self._process_agent_response(response.content)
2125                        # if heaven_main_callback:
2126                        #         heaven_main_callback(response_message)
2127                        
2128
2129                # # Extract tool calls from the response
2130                # print("\nDEBUG: Examining response for tool calls:")
2131                # print(f"Response: {response}")
2132                # print(f"Content: {response.content}")
2133                # print(f"Additional kwargs: {response.additional_kwargs}")
2134    
2135                tool_calls = []
2136                try:
2137                    if hasattr(response, 'tool_calls'):
2138                        print("Found tool_calls attribute")
2139                        tool_calls = response.tool_calls
2140                        print(f"Tool calls from attribute: {tool_calls}")
2141                    elif isinstance(response.content, list):
2142                        print("Found list content")
2143                        tool_calls = [
2144                            item for item in response.content 
2145                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2146                        ]
2147                        print(f"Tool calls from list: {tool_calls}")
2148                    elif 'tool_calls' in response.additional_kwargs:
2149                        print("Found tool_calls in additional_kwargs")
2150                        tool_calls = response.additional_kwargs['tool_calls']
2151                        print(f"Tool calls from kwargs: {tool_calls}")
2152                except Exception as e:
2153                    print(f"Error examining response: {e}")
2154    
2155                print(f"Extracted tool_calls: {tool_calls}")
2156    
2157                # Handle tool calls up to max_tool_calls limit
2158                current_tool_calls = tool_calls
2159    
2160                while current_tool_calls and tool_call_count < self.max_tool_calls:
2161                    new_tool_calls = []
2162                    for tool_call in current_tool_calls:
2163                        if tool_call_count >= self.max_tool_calls:
2164                            break
2165                        try:
2166                            # Try OpenAI style
2167                            if 'function' in tool_call:
2168                                tool_name = tool_call['function']['name']
2169                                tool_args = eval(tool_call['function']['arguments'])
2170                                tool_id = tool_call.get('id', '')
2171                            # Try Anthropic style
2172                            elif 'name' in tool_call:
2173                                tool_name = tool_call['name']
2174                                tool_args = tool_call.get('input', tool_call.get('args', {}))
2175                                tool_id = tool_call.get('id', '')
2176                            # Fallback
2177                            else:
2178                                tool_name = tool_call.get('name', '')
2179                                tool_args = tool_call.get('args', {})
2180                                tool_id = tool_call.get('id', '')
2181    
2182                            # Find matching tool
2183                            matching_tools = [
2184                                tool for tool in self.tools
2185                                if (tool.base_tool.name.lower() if hasattr(tool, 'base_tool') else tool.name.lower()) == tool_name.lower()
2186                            ]
2187
2188                            if matching_tools:
2189                                tool = matching_tools[0]
2190
2191                                # tool_result = await tool._arun(**tool_args)
2192                               
2193                                # Execute the tool and get its result
2194                                # Throttle to prevent CPU spin with fast models (MiniMax)
2195                                await asyncio.sleep(0.1)
2196                                if hasattr(tool, 'base_tool'):
2197                                    tool_result = await tool._arun(**tool_args)
2198                                else:
2199                                    from langchain_core.runnables import RunnableConfig
2200                                    config = RunnableConfig()
2201                                    raw_result = await tool._arun(config=config, **tool_args)
2202                                    tool_result = ToolResult(output=str(raw_result))
2203                                with open('/tmp/streamlit_debug.log', 'a') as f:
2204                                    f.write(f"\Tool result: {tool_result}")
2205                                # except ToolError as e:
2206                                #     # The error is already formatted with tool name in _arun
2207                                #     tool_result = ToolResult(error=str(e))
2208                                #     with open(tool_log_path, 'a') as f:
2209                                #         f.write(f"\nTool error: {e}\n")
2210                                
2211    
2212                                print("\n=== BEFORE ADDING TOOL MESSAGES ===")
2213                                for i, msg in enumerate(conversation_history):
2214                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2215    
2216                                # Handle tool messages based on provider
2217                                # if self.config.provider == ProviderEnum.OPENAI or self.config.provider == ProviderEnum.GROQ:
2218                                if self.config.provider in [ProviderEnum.OPENAI, ProviderEnum.GROQ, ProviderEnum.DEEPSEEK]:
2219                                    # OpenAI requires tool calls in additional_kwargs
2220                                    conversation_history.append(
2221                                        AIMessage(
2222                                            content="",  # OpenAI doesn't want content for tool calls
2223                                            additional_kwargs={
2224                                                "tool_calls": [{
2225                                                    "id": tool_id,
2226                                                    "type": "function",
2227                                                    "function": {
2228                                                        "name": tool_name,
2229                                                        "arguments": json.dumps(tool_args)
2230                                                        # str(tool_args)
2231                                                    }
2232                                                }]
2233                                            }
2234                                        )
2235                                    )
2236                                    # Then add the tool result
2237                                    tool_message_content = str(tool_result.error) if tool_result.error else str(tool_result.output) # changed for openai
2238                                    tool_result_message = ToolMessage(
2239                                        content=str(tool_message_content),
2240                                        tool_call_id=tool_id,
2241                                        additional_kwargs={
2242                                            "name": tool_name,
2243                                            "function": {"name": tool_name, "arguments": json.dumps(tool_args)
2244                                            # str(tool_args)
2245                                            }
2246                                        }
2247                                    )
2248                                    conversation_history.append(tool_result_message)
2249                                #####
2250                                elif self.config.provider == ProviderEnum.GOOGLE:
2251                                    # Gemini (via LangChain) expects a ToolMessage directly associated
2252                                    # with the tool_call_id from the preceding AIMessage's tool_calls.
2253                                    # We primarily need to construct the ToolMessage with the result.
2254                                    # The AIMessage that contained the tool_call request should already
2255                                    # be in the history from the model's previous turn.
2256                                    
2257                                    if tool_result.error:
2258                                        tool_message_content = str(tool_result.error)
2259                                        # Optional: Keep system prompt swap logic if needed for Gemini too
2260                                        # sys_msg_idx = next((i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage)), 0)
2261                                        # conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2262                                    elif tool_result.base64_image:
2263                                         # Gemini can handle images in ToolMessages if formatted correctly
2264                                        tool_message_content=[
2265                                            {
2266                                                'type': 'image_url', # Gemini prefers image_url format
2267                                                'image_url': f"data:image/png;base64,{tool_result.base64_image}"
2268                                            },
2269                                            {"type": "text", "text": "Image from tool execution."} # Context is helpful
2270                                        ]
2271                                    else:
2272                                        tool_message_content = str(tool_result.output)
2273            
2274                                    tool_message = ToolMessage(
2275                                        content=tool_message_content,
2276                                        tool_call_id=tool_id,
2277                                        name=tool_name
2278                                    )
2279                                    conversation_history.append(tool_message)
2280                                #####
2281                                else:
2282                                    # Anthropic and others use the original format
2283                                    conversation_history.append(
2284                                        AIMessage(
2285                                            content=[{
2286                                                "type": "tool_use",
2287                                                "id": tool_id,
2288                                                "name": tool_name,
2289                                                "input": tool_args
2290                                            }]
2291                                        )
2292                                    )
2293                                    
2294                                    # THIS BLOCK NEEDS TO BE IF TOOLRESULT ELSE TOOLERROR
2295                                    if tool_result.error:
2296                                        tool_message_content = str(tool_result.error)
2297                                            # Store current system message position for reference
2298                                        sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2299                                        # Swap to tool debug mode
2300                                        conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2301                                    elif tool_result.base64_image:
2302                                        tool_message_content=[{
2303                                            'type': 'image', 
2304                                            'source': {
2305                                                'type': 'base64', 
2306                                                'media_type': 'image/png', 
2307                                                'data': f"{tool_result.base64_image}"
2308                                                }
2309                                            },
2310                                            {"type": "text", "text": "Describe this image."}
2311                                        ]
2312                                    else:
2313                                        # We know tool_result.output exists because _arun guarantees either
2314                                        # error or output will be set
2315                                        tool_message_content = str(tool_result.output)
2316
2317                                    conversation_history.append(
2318                                        ToolMessage(
2319                                            # content=str(tool_result.output),  # Just the output string
2320                                            content=tool_message_content,
2321                                            tool_call_id=tool_id,
2322                                            name=tool_name
2323                                        )
2324                                    )
2325                                    # treverse the conversation_history and remove any all screen shot tool message 
2326                                    # except for the current one we just added (fix > 200,000 token issue)
2327                                    # for item in conversation_history[-2::-1]:
2328                                    #     if len(str(item.content)) > 100000:
2329                                    #         item.content="Removed old image that is no longer needed"
2330                                ### Unindented these 2 tabs
2331                                with open(tool_log_path, 'a') as f:
2332                                    f.write(f"\nAbout to call tool_output_callback with result: {tool_result.output}\n")
2333                                    f.write(f"tool_id: {tool_id}\n")
2334                                
2335                                tool_output_callback(tool_result, tool_id)
2336                                if heaven_main_callback:
2337                                    heaven_main_callback(conversation_history[-1]) # the tool message
2338                                with open(tool_log_path, 'a') as f:
2339                                    f.write("After tool_callback\n")
2340                                   
2341    
2342                                print("\n=== AFTER ADDING TOOL MESSAGES ===")
2343                                for i, msg in enumerate(conversation_history):
2344                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2345    
2346                                # Get AI's response about the tool result
2347                                # print("\n=== GETTING AI RESPONSE ABOUT TOOL RESULT ===")
2348                                with open(tool_log_path, 'a') as f:
2349                                    f.write(f"\nAI should be called next...\n")
2350                                    f.write(f"Coversation_History:\n    {conversation_history}\n")
2351                                result_response = await self.chat_model.ainvoke(conversation_history)
2352                                
2353                                sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2354                                conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
2355                                if heaven_main_callback:
2356                                        heaven_main_callback(result_response)
2357                                if result_response:
2358                                    with open(tool_log_path, 'a') as f:
2359                                        f.write(f"\nGot AI response after tool: {result_response.content}\n")
2360                                # print(f"===Result response===:\n\n{result_response}\n\n===/result response===")
2361                                ###### Add output callback here
2362                                
2363    
2364                                # if (
2365                                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2366                                #     and isinstance(result_response, AIMessage)
2367                                #     and (
2368                                #         result_response.tool_calls                                  # standard field
2369                                #         or result_response.additional_kwargs.get("tool_calls")      # legacy field
2370                                #     )
2371                                # ):
2372                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage) and not result_response.content and (result_response.tool_calls or result_response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2373                                if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2374                                    
2375                                    
2376                                    # Clean response for conversation_history
2377                                    if isinstance(result_response.content, list):
2378                                        # Extract only text, ignore thinking blocks
2379                                        text_content = []
2380                                        for item in result_response.content:
2381                                            if isinstance(item, str):
2382                                                text_content.append(item)
2383                                            elif isinstance(item, dict) and item.get('type') == 'text':
2384                                                text_content.append(item.get('text', ''))
2385                                        
2386                                        # Create cleaned response with simple string content
2387                                        cleaned_result_response = AIMessage(
2388                                            content=' '.join(text_content),  # Simple string, not list!
2389                                            additional_kwargs=result_response.additional_kwargs,
2390                                            tool_calls=result_response.tool_calls if hasattr(result_response, 'tool_calls') else []
2391                                        )
2392                                        conversation_history.append(cleaned_result_response)
2393                                    else:
2394                                        conversation_history.append(result_response)
2395                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2396                                #     # --- THE FIX ---
2397                                #     # Append the ORIGINAL response object. LangChain needs its structure.
2398                                #     conversation_history.append(result_response)
2399                                #     # self._process_agent_response(result_response)
2400                                #     output_callback(result_response)
2401                                #     # if heaven_main_callback:
2402                                #     #     heaven_main_callback(result_response)
2403                                # else:
2404                                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2405                                    if isinstance(result_response.content, list):
2406                                        thinking_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2407                                        if thinking_content:
2408                                            message3 = AIMessage(content=thinking_content)
2409                                            conversation_history.append(message3)
2410                                        text_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'text']
2411                                        if text_content:
2412                                            message = AIMessage(content=text_content)
2413                                            conversation_history.append(message)
2414                                            self._process_agent_response(text_content)
2415                                            output_callback(message)  # Pass the AIMessage object
2416                                            # if heaven_main_callback:
2417                                            #     heaven_main_callback(message)
2418                                        tool_use_content = [block for block in result_response.content if block.get('type') == 'tool_use']
2419                                        if tool_use_content:
2420                                            message2 = AIMessage(content=tool_use_content)
2421                                            output_callback(message2)
2422                                            # if heaven_main_callback:
2423                                            #     heaven_main_callback(message2)
2424                                    elif isinstance(result_response.content, str):
2425                                        message = AIMessage(content=result_response.content)
2426                                        conversation_history.append(message)
2427                                        self._process_agent_response(result_response.content)
2428                                        output_callback(message)  # Pass the AIMessage object
2429                                        # if heaven_main_callback:
2430                                        #     heaven_main_callback(message)
2431                                
2432                                # print("\n=== CONVERSATION HISTORY AFTER AI RESPONSE ABOUT TOOL ===")
2433                                # for i, msg in enumerate(conversation_history):
2434                                #     print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2435
2436                                # This may be redundant
2437                                # Process the AI's commentary if in agent mode
2438                                if isinstance(result_response, AIMessage):
2439                                    self._process_agent_response(result_response.content)
2440
2441                                # Now check whether the result_response includes new tool calls
2442                                new_calls = []
2443                                try:
2444                                    if hasattr(result_response, 'tool_calls'):
2445                                        new_calls = result_response.tool_calls
2446                                    elif isinstance(result_response.content, list):
2447                                        new_calls = [
2448                                            item for item in result_response.content 
2449                                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2450                                        ]
2451                                    elif 'tool_calls' in result_response.additional_kwargs:
2452                                        new_calls = result_response.additional_kwargs['tool_calls']
2453                                except Exception as e:
2454                                    print(f"Error examining result_response: {e}")
2455    
2456                                if new_calls:
2457                                    new_tool_calls.extend(new_calls)
2458    
2459                                tool_call_count += 1
2460                                if tool_call_count >= self.max_tool_calls:
2461                                    # NEW: Handle any pending tool calls that won't be processed
2462                                    # if current_tool_calls:
2463                                    #     # There are still tool calls queued that we're about to abandon
2464                                    #     for pending_tool in current_tool_calls:
2465                                    #         # Extract the tool info (handle different formats)
2466                                    #         if 'function' in pending_tool:
2467                                    #             tool_id = pending_tool.get('id', '')
2468                                    #             tool_name = pending_tool['function']['name']
2469                                    #         else:
2470                                    #             tool_id = pending_tool.get('id', '')
2471                                    #             tool_name = pending_tool.get('name', '')
2472                                            
2473                                    #         # Inject dummy ToolMessage for each orphaned call
2474                                    #         dummy_msg = ToolMessage(
2475                                    #             content="Error: The underlying system stopped this tool call from completing. It was interrupted. Once the user responds, the tool count will be reset.",
2476                                    #             tool_call_id=tool_id,
2477                                    #             name=tool_name
2478                                    #         )
2479                                    #         conversation_history.append(dummy_msg)
2480                                    #### NEW
2481                                        # Add a message informing the AI that max tool count was reached
2482                                    break_message = f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}. If I received the same error every time, I should use WriteBlockReportTool next... Waiting for next iteration."
2483                                    break_ai_message = AIMessage(content=break_message)
2484                                    conversation_history.append(break_ai_message)
2485                                    output_callback(break_message)
2486                                    if heaven_main_callback:
2487                                        heaven_main_callback(break_ai_message)
2488                                    # Clear the tool queue for this iteration
2489                                    current_tool_calls = []
2490                                    print(f"Maximum tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}")
2491                                    break  # Exit tool loop and continue to next iteration
2492                            else:
2493                                print(f"No matching tool found for {tool_name}")
2494                        except Exception as e:
2495                            print(f"Error processing tool call: {tool_call}")
2496                            print(f"Error details: {e}")
2497                            continue
2498                    # Prepare to process any new tool calls that came in the follow-up response
2499                    current_tool_calls = new_tool_calls
2500    
2501                
2502                
2503                # Process the agent response if in agent mode
2504                if self.goal and isinstance(response, AIMessage):
2505                    self._process_agent_response(response.content)
2506                    
2507                    # print(f"\nDEBUG TASK STATE: current_task={self.current_task}, current_iteration={self.current_iteration}, max_iterations={self.max_iterations}, task_list={self.task_list}")
2508    
2509                # Increment iteration count and break if the goal is met
2510                self.current_iteration += 1
2511    
2512                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
2513                    break
2514    
2515            # Update final history and return
2516            print("\n=== FINAL HISTORY ===")
2517            for i, msg in enumerate(conversation_history):
2518                print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2519            self.history.messages = conversation_history
2520            self.look_for_particular_tool_calls()
2521            self.history.messages = conversation_history
2522            # Save history and get potentially new history_id
2523            try:
2524                
2525                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
2526                # print(f"Agent name: {self.name}")
2527                # print(f"Current history: {self.history}")
2528                self.history.agent_status = self.save_status()
2529                saved_history_id = self.history.save(self.name)
2530                # print("===DEBUG AFTER SAVE ATTEMPT===")
2531                self.look_for_particular_tool_calls()
2532                return {
2533                    "history": self.history,
2534                    "history_id": saved_history_id,
2535                    "agent_name": self.name,
2536                    "agent_status": self.history.agent_status  # Add this
2537                }
2538            except Exception as save_error:
2539                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
2540                print(f"Error type: {type(save_error)}")
2541                print(f"Error message: {str(save_error)}")
2542                # Log the error but don't fail the run
2543                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
2544                
2545                return {
2546                    "history": self.history,
2547                    "history_id": getattr(self.history, 'history_id', "No history ID"),
2548                    "agent_name": self.name,
2549                    "save_error": str(save_error),
2550                    "agent_status": self.save_status()  # Add this here too
2551                }
2552            return self.history
2553    
2554        except Exception as e:
2555            raise RuntimeError(f"Agent run failed: {str(e)}") from e
2556
2557
2558
2559
2560
2561  ### UNI
2562
2563    
2564    def _prepare_tools_for_uni_api(self) -> List[Dict[str, Any]]:
2565        """Convert HEAVEN and LangChain tools to OpenAI format for uni-api"""
2566        if not self.tools:
2567            return []
2568        
2569        openai_tools = []
2570        for tool in self.tools:
2571            try:
2572                if hasattr(tool, 'get_openai_function'):
2573                    # HEAVEN tool with get_openai_function method
2574                    openai_tool = tool.get_openai_function()
2575                    openai_tools.append(openai_tool)
2576                elif hasattr(tool, 'args_schema') and hasattr(tool, 'name'):
2577                    # LangChain StructuredTool - convert schema to OpenAI format
2578                    from langchain_core.utils.function_calling import convert_to_openai_function
2579                    function_schema = convert_to_openai_function(tool)
2580                    # Wrap in proper OpenAI tool format
2581                    openai_tool = {
2582                        "type": "function",
2583                        "function": function_schema
2584                    }
2585                    openai_tools.append(openai_tool)
2586                else:
2587                    print(f"Unknown tool type, skipping: {tool}")
2588            except Exception as e:
2589                tool_name = getattr(tool, 'name', str(type(tool)))
2590                print(f"Error converting tool {tool_name} to OpenAI format: {e}")
2591        
2592        return openai_tools
2593
2594    async def _execute_tool_calls_uni(self, tool_calls: List[Dict[str, Any]], tool_output_callback: Optional[Callable] = None) -> List[Dict[str, Any]]:
2595        """Execute tool calls in uni-api (OpenAI) format and return tool messages"""
2596        import json
2597        
2598        tool_messages = []
2599        
2600        for tool_call in tool_calls:
2601            try:
2602                # DEBUG: Print the exact tool_call object
2603                print(f"🔧 DEBUG: Processing tool_call: {json.dumps(tool_call, indent=2)}")
2604                
2605                # Extract tool info from OpenAI format
2606                tool_id = tool_call["id"]
2607                tool_name = tool_call["function"]["name"]
2608                tool_args = json.loads(tool_call["function"]["arguments"])
2609                
2610                print(f"🔧 DEBUG: Extracted tool_id='{tool_id}', tool_name='{tool_name}'")
2611                
2612                # Find matching tool (HEAVEN or LangChain)
2613                matching_tools = []
2614                for tool in self.tools:
2615                    if hasattr(tool, 'base_tool'):
2616                        # HEAVEN tool
2617                        if tool.base_tool.name.lower() == tool_name.lower():
2618                            matching_tools.append(tool)
2619                    elif hasattr(tool, 'name'):
2620                        # LangChain tool (StructuredTool)
2621                        if tool.name.lower() == tool_name.lower():
2622                            matching_tools.append(tool)
2623                
2624                if matching_tools:
2625                    tool = matching_tools[0]
2626                    
2627                    # Execute the tool differently based on type
2628                    if hasattr(tool, 'base_tool'):
2629                        # HEAVEN tool - returns ToolResult
2630                        tool_result = await tool._arun(**tool_args)
2631                        if tool_output_callback:
2632                            tool_output_callback(tool_result, tool_id)
2633                        tool_content = str(tool_result.error) if tool_result.error else str(tool_result.output)
2634                    else:
2635                        # LangChain/MCP tool - _arun needs config kwarg
2636                        from langchain_core.runnables import RunnableConfig
2637                        config = RunnableConfig()
2638                        raw_result = await tool._arun(config=config, **tool_args)
2639                        if tool_output_callback:
2640                            tool_output_callback(raw_result, tool_id)
2641                        tool_content = str(raw_result)
2642                    
2643                    tool_message = {
2644                        "role": "tool",
2645                        "tool_call_id": tool_id,
2646                        "name": tool_name,
2647                        "content": tool_content
2648                    }
2649                    
2650                    tool_messages.append(tool_message)
2651                    
2652                    # Check if TaskSystemTool was called
2653                    if tool_name == "TaskSystemTool":
2654                        self._handle_task_system_tool(tool_args)
2655                    # Check if WriteBlockReportTool was called
2656                    if tool_name == "WriteBlockReportTool":
2657                        # Mark that we're blocked - this will be checked by the caller
2658                        self.blocked = True
2659                        # Generate block report
2660                        block_report_md = self.create_block_report()
2661                        if block_report_md:
2662                            # Follow the established pattern: modify _current_extracted_content
2663                            if self._current_extracted_content is None:
2664                                self._current_extracted_content = {}
2665                            
2666                            # Add the block report
2667                            self._current_extracted_content["block_report"] = block_report_md
2668                            
2669                            # Update agent_status using the established save_status method
2670                            self.history.agent_status = self.save_status()
2671                    
2672                else:
2673                    # Tool not found
2674                    error_message = {
2675                        "role": "tool", 
2676                        "tool_call_id": tool_id,
2677                        "content": f"Error: Tool '{tool_name}' not found"
2678                    }
2679                    tool_messages.append(error_message)
2680            
2681            except Exception as e:
2682                # Tool execution error
2683                error_message = {
2684                    "role": "tool",
2685                    "tool_call_id": tool_call["id"],
2686                    "content": f"Error executing tool: {str(e)}"
2687                }
2688                tool_messages.append(error_message)
2689        
2690        return tool_messages
2691
2692    def _cleanse_dangling_tool_calls(self, uni_conversation_history: List[Dict], langchain_conversation_history: List[BaseMessage], reason: str = ""):
2693        """Remove dangling tool_calls from the last message in uni_conversation_history only."""
2694        if uni_conversation_history and uni_conversation_history[-1].get("tool_calls"):
2695            if "MULTIPLE_TOOL_CALLS" in reason and len(uni_conversation_history[-1]["tool_calls"]) > 1:
2696                print(f"🔧 CLEANSING {reason}: Keeping only first of {len(uni_conversation_history[-1]['tool_calls'])} tool_calls")
2697                uni_conversation_history[-1]["tool_calls"] = [uni_conversation_history[-1]["tool_calls"][0]]
2698            elif reason == "MAX_TOOL_CALLS":
2699                print(f"🔧 CLEANSING {reason}: Removing {len(uni_conversation_history[-1]['tool_calls'])} dangling tool_calls")
2700                uni_conversation_history[-1] = {
2701                    "role": "assistant", 
2702                    "content": uni_conversation_history[-1].get("content", "")
2703                }
2704
2705    # async def run_on_uni_api(
2706    #     self, 
2707    #     prompt: Optional[str] = None,
2708    #     output_callback: Optional[Callable] = None,
2709    #     tool_output_callback: Optional[Callable] = None,
2710    #     heaven_main_callback: Optional[Callable] = None
2711    # ) -> Dict[str, Any]:
2712    #     """
2713    #     Run agent using uni-api instead of LangChain providers.
2714    #     Uses parallel uni/langchain conversation tracking.
2715    #     """
2716    #     # Store callbacks for tool execution
2717    #     self._current_output_callback = output_callback
2718    #     self._current_tool_callback = tool_output_callback
2719        
2720    #     # Convert existing history to uni-api format
2721    #     uni_conversation_history = self.history.to_uni_messages()
2722    #     langchain_conversation_history = self.history.messages.copy()
2723        
2724    #     # Ensure system message is present and correct
2725    #     if not uni_conversation_history or uni_conversation_history[0]["role"] != "system":
2726    #         uni_conversation_history.insert(0, {
2727    #             "role": "system", 
2728    #             "content": self.config.system_prompt
2729    #         })
2730    #         langchain_conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
2731    #     elif uni_conversation_history[0]["content"] != self.config.system_prompt:
2732    #         uni_conversation_history[0]["content"] = self.config.system_prompt
2733    #         langchain_conversation_history[0] = SystemMessage(content=self.config.system_prompt)
2734        
2735    #     # Add new user prompt if provided
2736    #     if prompt:
2737    #         uni_conversation_history.append({"role": "user", "content": prompt})
2738    #         langchain_conversation_history.append(HumanMessage(content=prompt))
2739            
2740    #         # Detect agent commands
2741    #         self._detect_agent_command(prompt)
2742        
2743    #     # Prepare tools for uni-api
2744    #     openai_tools = self._prepare_tools_for_uni_api()
2745        
2746    #     # Initialize blocked flag
2747    #     self.blocked = False
2748        
2749    #     # Main iteration loop
2750    #     while self.current_iteration <= self.max_iterations:
2751    #         tool_call_count = 0
2752            
2753    #         # CLEANSE DANGLING TOOL_CALLS: Before adding agent mode prompt, clean up any dangling tool_calls
2754    #         self._cleanse_dangling_tool_calls(uni_conversation_history, langchain_conversation_history, "BEFORE_AGENT_PROMPT")
2755            
2756    #         # Handle agent mode formatting
2757    #         if self.goal:
2758    #             agent_prompt = self._format_agent_prompt()
2759    #             uni_conversation_history.append({"role": "user", "content": agent_prompt})
2760    #             langchain_conversation_history.append(HumanMessage(content=agent_prompt))
2761            
2762    #         # DUO sidechain logic (if enabled)
2763    #         if (self.duo_enabled and len(uni_conversation_history) > 2 
2764    #             and uni_conversation_history[-1]["role"] == "user"):
2765                
2766    #             original_user = langchain_conversation_history[-1]
2767    #             original_sys = langchain_conversation_history[0]
2768                
2769    #             try:
2770    #                 # Process with DUO using LangChain messages
2771    #                 duo_sys = SystemMessage(content=self.duo_system_prompt)
2772    #                 lc_messages = langchain_conversation_history.copy()
2773    #                 lc_messages[0] = duo_sys
2774                    
2775    #                 new_content = f"===ENTERING CHALLENGER MODE===\\n\\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\\n\\n{original_user.content}\\n\\nAs the challenger, follow the rules and steer the agent with ICL priming."
2776    #                 lc_messages[-1] = HumanMessage(content=new_content)
2777                    
2778    #                 duo_response = await self.duo_chat.ainvoke(lc_messages)
2779                    
2780    #                 if duo_response:
2781    #                     # Update both histories with DUO injection
2782    #                     enhanced_content = f"{original_user.content}\\n\\n```\\n===Challenger Injection===\\n\\nDo not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\\n\\n{duo_response.content}\\n\\n===/Challenger Injection===\\n```\\n\\n"
2783    #                     uni_conversation_history[-1]["content"] = enhanced_content
2784    #                     langchain_conversation_history[-1] = HumanMessage(content=enhanced_content)
2785                    
2786    #             finally:
2787    #                 pass
2788            
2789    #         # Build payload for uni-api
2790    #         payload = {
2791    #             "max_tokens": getattr(self.config, 'max_tokens', 4000),
2792    #             "temperature": getattr(self.config, 'temperature', 0.7)
2793    #         }
2794            
2795    #         if openai_tools:
2796    #             payload["tools"] = openai_tools
2797    #             payload["tool_choice"] = "auto"
2798    #             payload["parallel_tool_calls"] = False  # Fix for uni-api multiple tool calls bug
2799            
2800    #         try:
2801    #             # Call uni-api through unified_chat
2802    #             result = self.unified_chat.invoke_uni_api(
2803    #                 model=self.config.model,
2804    #                 uni_messages=uni_conversation_history,
2805    #                 **payload
2806    #             )
2807                
2808    #             # Extract token usage for context window management
2809    #             if "usage" in result and isinstance(result["usage"], dict):
2810    #                 self.context_window_config.update_from_uni_api(result["usage"])
2811    #                 # Store token usage in history metadata
2812    #                 if not hasattr(self.history, 'metadata'):
2813    #                     self.history.metadata = {}
2814    #                 self.history.metadata["last_token_usage"] = result["usage"]
2815    #                 self.history.metadata["current_tokens"] = result["usage"].get("total_tokens", 0)
2816    #             else:
2817    #                 # Fallback: use tiktoken estimation for workspace
2818    #                 from .utils.token_counter import count_tokens_in_messages
2819    #                 workspace_tokens = count_tokens_in_messages(langchain_conversation_history, self.context_window_config.model)
2820    #                 self.context_window_config.update_workspace_tokens(workspace_tokens)
2821    #                 if not hasattr(self.history, 'metadata'):
2822    #                     self.history.metadata = {}
2823    #                 self.history.metadata["current_tokens"] = self.context_window_config.current_tokens
2824                
2825    #             assistant_message = result["choices"][0]["message"]
2826                
2827    #             # DEBUG: Print the exact assistant_message object from uni-api
2828    #             print(f"🔍 DEBUG: assistant_message from uni-api: {json.dumps(assistant_message, indent=2)}")
2829                
2830    #             # CLEANSE MULTIPLE TOOL_CALLS BEFORE APPENDING using our method
2831    #             temp_history = [assistant_message]
2832    #             self._cleanse_dangling_tool_calls(temp_history, [], "MULTIPLE_TOOL_CALLS")
2833    #             assistant_message = temp_history[0]
2834                
2835    #             # Handle tool calls vs regular response
2836    #             if assistant_message.get("tool_calls"):
2837    #                 print(f"🔍 TOOL CALLS DETECTED: {len(assistant_message['tool_calls'])} tool calls")
2838    #                 # For tool calls, ensure content is empty string instead of null for OpenAI API compatibility
2839    #                 if assistant_message.get("content") is None:
2840    #                     assistant_message["content"] = ""
2841                    
2842    #                 # CLEANSE MULTIPLE TOOL_CALLS: Only process the FIRST tool call at a time
2843    #                 if len(assistant_message["tool_calls"]) > 1:
2844    #                     print(f"🔧 CLEANSING MULTIPLE TOOL_CALLS: Keeping only first of {len(assistant_message['tool_calls'])} tool_calls")
2845    #                     assistant_message["tool_calls"] = [assistant_message["tool_calls"][0]]
2846                    
2847    #                 # Store original for potential cleansing
2848    #                 original_assistant_message = assistant_message.copy()
2849                    
2850    #                 # For tool calls, add to uni history AFTER applying workaround
2851    #                 uni_conversation_history.append(assistant_message)
2852                    
2853    #                 # Add the AIMessage with tool_calls to langchain history (OpenAI style)
2854    #                 tool_call_ai_message = AIMessage(
2855    #                     content="",  # OpenAI doesn't want content for tool calls
2856    #                     additional_kwargs={
2857    #                         "tool_calls": assistant_message["tool_calls"]
2858    #                     }
2859    #                 )
2860    #                 langchain_conversation_history.append(tool_call_ai_message)
2861                    
2862    #                 # Trigger callbacks for tool call message
2863    #                 if output_callback:
2864    #                     output_callback(tool_call_ai_message)
2865                    
2866    #                 if heaven_main_callback:
2867    #                     heaven_main_callback(assistant_message)
2868                    
2869    #                 while assistant_message.get("tool_calls") and tool_call_count < self.max_tool_calls:
2870    #                     # DEBUG: Print the exact assistant_message with tool_calls
2871    #                     print(f"🔧 DEBUG: PROCESSING ASSISTANT MESSAGE: {json.dumps(assistant_message, indent=2)}")
2872    #                     print(f"🔧 DEBUG: TOOL_CALLS ARRAY: {json.dumps(assistant_message['tool_calls'], indent=2)}")
2873                        
2874    #                     # Execute tools and get tool messages
2875    #                     tool_messages = await self._execute_tool_calls_uni(assistant_message["tool_calls"], tool_output_callback)
2876                        
2877    #                     # DEBUG: Print the exact tool_messages we created - COMPLETE JSON
2878    #                     print(f"🔧 TOOL_MESSAGES WE CREATED - COMPLETE JSON:")
2879    #                     print(json.dumps(tool_messages, indent=2))
2880    #                     print(f"🔧 END TOOL_MESSAGES")
2881                        
2882    #                     # Add tool messages to both histories
2883    #                     uni_conversation_history.extend(tool_messages)
2884    #                     for tool_msg in tool_messages:
2885    #                         lc_tool_msg = ToolMessage(
2886    #                             content=tool_msg["content"],
2887    #                             tool_call_id=tool_msg["tool_call_id"]
2888    #                         )
2889    #                         langchain_conversation_history.append(lc_tool_msg)
2890                        
2891    #                     # Check if we're blocked (WriteBlockReportTool was called)
2892    #                     if self.blocked:
2893    #                         break
2894                        
2895    #                     # DEBUG: Print exact conversation history length before uni-api call
2896    #                     print(f"🚨 BEFORE UNI-API CALL: uni_conversation_history has {len(uni_conversation_history)} messages")
2897                        
2898    #                     # Get AI response to tool results
2899    #                     tool_result = self.unified_chat.invoke_uni_api(
2900    #                         model=self.config.model,
2901    #                         uni_messages=uni_conversation_history,
2902    #                         **payload
2903    #                     )
2904                        
2905    #                     assistant_message = tool_result["choices"][0]["message"]
2906                        
2907    #                     # DEBUG: Print the COMPLETE assistant_message object from uni-api - NO TRUNCATION
2908    #                     print(f"🔍 UNI-API ASSISTANT MESSAGE - COMPLETE JSON:")
2909    #                     print(json.dumps(assistant_message, indent=2))
2910    #                     print(f"🔍 END UNI-API ASSISTANT MESSAGE")
2911                        
2912                        
2913    #                     # CAPTURE CANCELLED TOOL_CALLS before cleansing
2914    #                     cancelled_tools = []
2915    #                     if tool_call_count >= self.max_tool_calls and assistant_message.get("tool_calls"):
2916    #                         for tc in assistant_message["tool_calls"]:
2917    #                             tool_name = tc["function"]["name"]
2918    #                             tool_args = tc["function"]["arguments"]
2919    #                             cancelled_tools.append(f"{tool_name}({tool_args})")
2920    #                         print(f"🔧 CLEANSING MAX_TOOL_CALLS: Removing {len(assistant_message['tool_calls'])} dangling tool_calls from assistant_message")
2921    #                         assistant_message = {
2922    #                             "role": "assistant", 
2923    #                             "content": assistant_message.get("content", "")
2924    #                         }
2925    #                     self._cleanse_dangling_tool_calls(assistant_message, [], "MULTIPLE_TOOL_CALLS")
2926                        
2927                        
2928    #                     # DEBUG: Extract tool_call_ids from the assistant message
2929    #                     if assistant_message.get("tool_calls"):
2930    #                         print(f"🔍 TOOL_CALL_IDS FROM UNI-API:")
2931    #                         for i, tc in enumerate(assistant_message["tool_calls"]):
2932    #                             print(f"  Tool Call {i}: ID = '{tc['id']}'")
2933    #                         print(f"🔍 END TOOL_CALL_IDS")
2934                        
2935    #                     # Handle None content from uni-api (happens with tool calls)
2936    #                     if assistant_message.get("content") is None:
2937    #                         assistant_message["content"] = ""
2938                        
2939                        
2940    #                     # Add cleansed message to both histories
2941    #                     uni_conversation_history.append(assistant_message)
2942    #                     lc_message = self.history.from_uni_messages([assistant_message]).messages[0]
2943    #                     langchain_conversation_history.append(lc_message)
2944                        
2945    #                     # Trigger callbacks
2946    #                     if output_callback:
2947    #                         output_callback(lc_message)
2948                        
2949    #                     if heaven_main_callback:
2950    #                         heaven_main_callback(assistant_message)
2951                        
2952    #                     tool_call_count += 1
2953                        
2954    #                     if tool_call_count >= self.max_tool_calls:
2955    #                         break_message = {
2956    #                             "role": "assistant",
2957    #                             "content": f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) reached after agent mode iteration {self.current_iteration}. I tried to call [{', '.join(cancelled_tools)}] but they were cancelled by the system. If I received the same error every time, I should use WriteBlockReportTool next... Waiting for next agent mode iteration."
2958    #                         }
2959    #                         uni_conversation_history.append(break_message)
2960    #                         lc_break_message = self.history.from_uni_messages([break_message]).messages[0]
2961    #                         langchain_conversation_history.append(lc_break_message)
2962                            
2963    #                         if output_callback:
2964    #                             output_callback(lc_break_message)
2965                            
2966    #                         break
2967                
2968    #             else:
2969    #                 # Regular response (no tool calls)
2970    #                 uni_conversation_history.append(assistant_message)
2971    #                 if assistant_message.get("content") is None:
2972    #                     assistant_message["content"] = ""
2973    #                 lc_message = self.history.from_uni_messages([assistant_message]).messages[0]
2974    #                 langchain_conversation_history.append(lc_message)
2975                    
2976    #                 # Trigger callbacks
2977    #                 if output_callback:
2978    #                     output_callback(lc_message)
2979                    
2980    #                 if heaven_main_callback:
2981    #                     heaven_main_callback(assistant_message)
2982                
2983    #             # Process agent response if in agent mode
2984    #             if self.goal and assistant_message.get("content"):
2985    #                 self._process_agent_response(assistant_message["content"])
2986                
2987    #             # CLEANSE DANGLING TOOL_CALLS: Before moving to next iteration, clean up any dangling tool_calls
2988    #             self._cleanse_dangling_tool_calls(uni_conversation_history, langchain_conversation_history, "MAX_TOOL_CALLS")
2989                
2990    #             # Check for completion
2991    #             self.current_iteration += 1
2992                
2993    #             if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or self.blocked:
2994    #                 break
2995            
2996    #         except Exception as e:
2997    #             error_msg = f"uni-api request failed: {str(e)}"
2998    #             print(error_msg)
2999    #             raise RuntimeError(error_msg)
3000        
3001    #     # Save final LangChain history
3002    #     self.history.messages = langchain_conversation_history
3003        
3004    #     # Save history
3005    #     try:
3006    #         self.history.agent_status = self.save_status()
3007    #         saved_history_id = self.history.save(self.name)
3008            
3009    #         return {
3010    #             "history": self.history,
3011    #             "history_id": saved_history_id,
3012    #             "agent_name": self.name,
3013    #             "agent_status": self.history.agent_status,
3014    #             "uni_api_used": True,
3015    #             "context_window_status": self.context_window_config.get_status(),
3016    #             "raw_response": result  # Include raw response for token extraction by AutoSummarizingAgent
3017    #         }
3018        
3019    #     except Exception as save_error:
3020    #         print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
3021    #         return {
3022    #             "history": self.history,
3023    #             "history_id": getattr(self.history, 'history_id', "No history ID"),
3024    #             "agent_name": self.name,
3025    #             "save_error": str(save_error),
3026    #             "agent_status": self.save_status(),
3027    #             "uni_api_used": True,
3028    #             "context_window_status": self.context_window_config.get_status(),
3029    #             "raw_response": result if 'result' in locals() else None
3030    #         }
3031
3032    async def run_on_uni_api(
3033        self,
3034        prompt: Optional[str] = None,
3035        output_callback: Optional[Callable] = None,
3036        tool_output_callback: Optional[Callable] = None,
3037        heaven_main_callback: Optional[Callable] = None,
3038    ) -> Dict[str, Any]:
3039        """
3040        Run agent using uni-api instead of LangChain providers.
3041        Enforces “one tool call at a time” without altering your
3042        original stop-message wording.
3043        """
3044        result = None
3045        # ---------- 0.  History bootstrap ----------
3046        self._current_output_callback = output_callback
3047        self._current_tool_callback = tool_output_callback
3048
3049        uni_conversation_history = self.history.to_uni_messages()
3050        langchain_conversation_history = self.history.messages.copy()
3051
3052        if not uni_conversation_history or uni_conversation_history[0]["role"] != "system":
3053            uni_conversation_history.insert(0, {"role": "system", "content": self.config.system_prompt})
3054            langchain_conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
3055        elif uni_conversation_history[0]["content"] != self.config.system_prompt:
3056            uni_conversation_history[0]["content"] = self.config.system_prompt
3057            langchain_conversation_history[0] = SystemMessage(content=self.config.system_prompt)
3058
3059        if prompt:
3060            uni_conversation_history.append({"role": "user", "content": prompt})
3061            langchain_conversation_history.append(HumanMessage(content=prompt))
3062            self._detect_agent_command(prompt)
3063
3064        # Resolve MCP tools before preparing for API
3065        await self.resolve_mcps()
3066
3067        openai_tools = self._prepare_tools_for_uni_api()
3068        self.blocked = False
3069
3070        # ---------- 1.  Iteration loop ----------
3071        while self.current_iteration <= self.max_iterations:
3072            tool_call_count = 0
3073            self._cleanse_dangling_tool_calls(
3074                uni_conversation_history, langchain_conversation_history, "MULTIPLE_TOOL_CALLS"
3075            )
3076
3077            if self.goal:
3078                agent_prompt = self._format_agent_prompt()
3079                uni_conversation_history.append({"role": "user", "content": agent_prompt})
3080                langchain_conversation_history.append(HumanMessage(content=agent_prompt))
3081
3082            payload = {
3083                "max_tokens": getattr(self.config, "max_tokens", 4000),
3084                "temperature": getattr(self.config, "temperature", 0.7),
3085                "parallel_tool_calls": False,
3086            }
3087            if openai_tools:
3088                payload["tools"] = openai_tools
3089                payload["tool_choice"] = "auto"
3090
3091            result = self.unified_chat.invoke_uni_api(
3092                model=self.config.model, uni_messages=uni_conversation_history, **payload
3093            )
3094            assistant_message = result["choices"][0]["message"]
3095
3096            # ---------- 2.  Single-tool clamp ----------
3097            self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3098
3099            # ---------- 3.  Tool-call branch ----------
3100            if assistant_message.get("tool_calls"):
3101                if assistant_message.get("content") is None:
3102                    assistant_message["content"] = ""
3103
3104                uni_conversation_history.append(assistant_message)
3105                langchain_conversation_history.append(
3106                    AIMessage(content="", additional_kwargs={"tool_calls": assistant_message["tool_calls"]})
3107                )
3108                if output_callback:
3109                    output_callback(langchain_conversation_history[-1])
3110                if heaven_main_callback:
3111                    heaven_main_callback(langchain_conversation_history[-1])
3112
3113                while assistant_message.get("tool_calls") and tool_call_count < self.max_tool_calls:
3114                    # execute the (single) tool call
3115                    tool_messages = await self._execute_tool_calls_uni(
3116                        assistant_message["tool_calls"], tool_output_callback
3117                    )
3118                    uni_conversation_history.extend(tool_messages)
3119                    for tm in tool_messages:
3120                        langchain_conversation_history.append(
3121                            ToolMessage(content=tm["content"], tool_call_id=tm["tool_call_id"])
3122                        )
3123
3124                    # Check if WriteBlockReportTool was called and auto-inject response
3125                    if self.blocked:
3126                        # Extract the required response from WriteBlockReportTool result
3127                        for tm in tool_messages:
3128                            if tm.get("name") == "WriteBlockReportTool":
3129                                response_msg = "I've created a block report and am waiting for the help I need"
3130                                
3131                                # Add to uni conversation layer
3132                                uni_conversation_history.append({
3133                                    "role": "assistant", 
3134                                    "content": response_msg
3135                                })
3136                                
3137                                # Add to langchain layer  
3138                                langchain_conversation_history.append(
3139                                    AIMessage(content=response_msg)
3140                                )
3141                                break
3142                        break
3143
3144                    tool_result = self.unified_chat.invoke_uni_api(
3145                        model=self.config.model, uni_messages=uni_conversation_history, **payload
3146                    )
3147                    assistant_message = tool_result["choices"][0]["message"]
3148                    
3149                    # ---------- 4.  Clamp again / MAX_TOOL_CALLS ----------
3150                    cancelled_tools = []  # Initialize to prevent NameError
3151                    if tool_call_count + 1 >= self.max_tool_calls and assistant_message.get("tool_calls"):
3152                        cancelled_tools = [
3153                            f"{tc['function']['name']}({tc['function']['arguments']})"
3154                            for tc in assistant_message["tool_calls"]
3155                        ]
3156                        assistant_message.pop("tool_calls", None)  # strip them but keep same dict
3157                    else:
3158                        self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3159
3160                    if assistant_message.get("content") is None:
3161                        assistant_message["content"] = ""
3162                    result = assistant_message
3163                    uni_conversation_history.append(assistant_message)
3164                    langchain_conversation_history.append(
3165                        self.history.from_uni_messages([assistant_message]).messages[0]
3166                    )
3167                    if output_callback:
3168                        output_callback(langchain_conversation_history[-1])
3169                    if heaven_main_callback:
3170                        heaven_main_callback(assistant_message)
3171
3172                    tool_call_count += 1
3173
3174                    if tool_call_count >= self.max_tool_calls:
3175                        break_message = {
3176                            "role": "assistant",
3177                            "content": (
3178                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
3179                                f"reached after agent mode iteration {self.current_iteration}. "
3180                                f"I tried to call [{', '.join(cancelled_tools)}] but they were "
3181                                "cancelled by the system. If I received the same error every time, "
3182                                "I should use WriteBlockReportTool next... Waiting for next agent "
3183                                "mode iteration."
3184                            ),
3185                        }
3186                        uni_conversation_history.append(break_message)
3187                        langchain_conversation_history.append(
3188                            self.history.from_uni_messages([break_message]).messages[0]
3189                        )
3190                        if output_callback:
3191                            output_callback(langchain_conversation_history[-1])
3192                        break
3193
3194            # ---------- 5.  Text-only branch ----------
3195            else:
3196                if assistant_message.get("content") is None:
3197                    assistant_message["content"] = ""
3198                uni_conversation_history.append(assistant_message)
3199                langchain_conversation_history.append(
3200                    self.history.from_uni_messages([assistant_message]).messages[0]
3201                )
3202                if output_callback:
3203                    output_callback(langchain_conversation_history[-1])
3204                if heaven_main_callback:
3205                    heaven_main_callback(langchain_conversation_history[-1])
3206
3207            # ---------- 6.  Agent-mode bookkeeping ----------
3208            if self.goal and assistant_message.get("content"):
3209                self._process_agent_response(assistant_message["content"])
3210
3211            self._cleanse_dangling_tool_calls(
3212                uni_conversation_history, langchain_conversation_history, "MAX_TOOL_CALLS"
3213            )
3214
3215            self.current_iteration += 1
3216            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or self.blocked:
3217                break
3218
3219        # ---------- 7.  Persist history ----------
3220        self.history.messages = langchain_conversation_history
3221        self.history.agent_status = self.save_status()
3222        saved_history_id = self.history.save(self.name)
3223
3224        return {
3225            "history": self.history,
3226            "history_id": saved_history_id,
3227            "agent_name": self.name,
3228            "agent_status": self.history.agent_status,
3229            "uni_api_used": True,
3230            "context_window_status": self.context_window_config.get_status(),
3231            "raw_response": result,
3232        }
3233
3234
3235  ###
3236    
3237  
3238    def _detect_agent_command(self, user_input: str):
3239        """Detects goal and iterations from user input."""
3240        match = re.search(r"agent goal=(.*?), iterations=(\d+)", user_input, re.IGNORECASE | re.DOTALL)
3241        if match:
3242            self.goal = match.group(1).strip()
3243            self.max_iterations = int(match.group(2))
3244            self.current_iteration = 1
3245            self.task_list = ["create_task_list"]
3246            self.current_task = "create_task_list"
3247            self.completed = False
3248    
3249    def _format_agent_prompt(self) -> str:
3250        """Formats the agentic prompt dynamically."""
3251        # Set defaults if not defined
3252        goal = self.goal if self.goal is not None else "There is no goal set!"
3253        current_task = self.current_task if self.current_task is not None else f"Update the task list for {self.goal}" # used to be "reason about what to do next"
3254        continuation_text = ""
3255        if hasattr(self, 'continuation_prompt') and self.continuation_prompt:
3256            continuation_text = f"{self.continuation_prompt}"
3257            if continuation_text == self.goal:
3258                continuation_text = ""
3259            self.continuation_prompt = None  # Clear it after use
3260        return (
3261        f"""
3262        # ===AGENT MODE IS ENGAGED===
3263        ## ❗ **Critical Instructions for Task Execution:**
3264        - Use the **TaskSystemTool** to manage your task list. Work on **one task at a time**.
3265
3266        1. **Create/update task list**: Call TaskSystemTool with operation="update_tasks", tasks=["task 1", "task 2", "task 3"]
3267           Then call TaskSystemTool with operation="complete_task", task_name="create_task_list" to complete the first task.
3268        **THEN DO THE TASKS IN THE TASK LIST, including any required tool calls. Each iteration allows {self.max_tool_calls} tool calls during agent mode!!! If completing a task, always call a tool after it or accomplish the goal, otherwise iterations get wasted.**
3269        2. **After completing any task**: Call TaskSystemTool with operation="complete_task", task_name="<the task you just finished>"
3270        THEN CONTINUE TO THE NEXT TASK.
3271        3. **When all tasks are done**: Call TaskSystemTool with operation="goal_accomplished"
3272        4. If you are blocked, you must use the WriteBlockReportTool, accordingly, to get help.
3273        """
3274        + (f"""You can also use these XML tag fence patterns when outputting specific deliverables to make the agent mode system capture them:[\n{self.additional_kw_instructions}\n]""" if self.additional_kw_instructions != "" else "")
3275        + f"""
3276        
3277        ---
3278        
3279        #### Notes
3280        - 1. An iteration is a LLM generation with oneOf: thinking, text, tool call, sequence of [thinking, text | thinking, text, tool call | text, tool call | tool call] -- up to {self.max_tool_calls} total tool calls per iteration. Any sequence ending with a tool call keeps current iteration alive. Any sequence ending with thinking or text terminates the iteration. 
3281        - 2. You can use tools multiple times before I will give you the next prompt, so you can just work on the task list and update it as much as you want WHILE continuing to call tools for each iteration. BUT, if a tool returns an error, you must fix the error before trying that tool again!!! 
3282        """
3283                +
3284                f"\n\#### Current State"
3285                f"\n\n- **Goal:** {goal}"
3286                f"\n\n- **Current Task List \"{self.task_list}\"**"
3287                f"\n\n- **Current Iteration:** {self.current_iteration} of {self.max_iterations}"
3288                f"\n\n- **Current Task:** {current_task}\n\n"
3289                f"\n\n{continuation_text}"
3290                + "\n"
3291        )
3292
3293    def _process_agent_response(self, response_content: Union[str, List[Any]]):
3294        ######## FUTURE FIX
3295        # Fix = examine all list elements (or recurse) instead of only index 0.
3296        ########
3297        # Handle AIMessage content formats
3298        # content_to_process = response_content
3299        def _flatten(obj):
3300
3301            if obj is None:
3302
3303                return ""
3304
3305            if isinstance(obj, str):
3306
3307                return obj
3308
3309            if isinstance(obj, dict):
3310
3311                return " ".join(_flatten(v) for v in obj.values())
3312
3313            if isinstance(obj, (list, tuple, set)):
3314
3315                return " ".join(_flatten(v) for v in obj)
3316
3317            if hasattr(obj, "content"):
3318
3319                return _flatten(obj.content)
3320
3321            return str(obj)
3322
3323
3324        content_to_process = _flatten(response_content)
3325        # if isinstance(content_to_process, list):
3326        #     # If it's a list of dicts with 'text' key
3327        #     if content_to_process and isinstance(content_to_process[0], dict):
3328        #         content_to_process = content_to_process[0].get('text', '')
3329        #     else:
3330        #         content_to_process = str(content_to_process[0]) if content_to_process else ""
3331        # elif not isinstance(content_to_process, str):
3332        #     content_to_process = str(content_to_process)      
3333        # Detect task list
3334        # Find all matches and take the last one since examples will be shown first
3335        task_list_matches = re.finditer(r"```update_task_list=\[(.*?)\]```", content_to_process, re.IGNORECASE)
3336        task_list_match = None
3337        for match in task_list_matches:
3338            task_list_match = match
3339            if task_list_match:
3340                raw_tasks = [task.strip().strip('"') for task in task_list_match.group(1).split(",")]
3341                if raw_tasks:  # Only update if we got tasks
3342                    self.task_list = raw_tasks
3343                    self.current_task = self.task_list[0]
3344                else:
3345                    self.task_list = []
3346                    self.current_task = None
3347        
3348 
3349
3350        # Detect task completion
3351        task_complete_match = re.search(r"```complete_task=(.*?)```", content_to_process, re.IGNORECASE)
3352        if task_complete_match:
3353            completed_task = task_complete_match.group(1).strip()
3354            self._complete_task(completed_task)
3355
3356        # Check for GOAL ACCOMPLISHED
3357        goal_accomplished_match = re.search(r"```GOAL ACCOMPLISHED```", content_to_process)
3358        if goal_accomplished_match:
3359            # Let the loop know to break, but don't modify current_task
3360            self.completed = True
3361            self.goal = None
3362          
3363        
3364    
3365        if self.additional_kws and self.additional_kw_instructions != "":
3366            if self._current_extracted_content is None:
3367                self._current_extracted_content = {}
3368        
3369            for kw in self.additional_kws:
3370                # kw_matches = re.finditer(f"```{kw}(.*?)```", content_to_process, re.DOTALL)
3371                md_pat  = re.finditer(
3372                    fr"```{kw}(.*?)```",
3373                    content_to_process,
3374                    re.DOTALL | re.IGNORECASE
3375                )
3376
3377                # ➋  XML-tag pattern  (new, collision-proof)
3378                xml_pat = re.finditer(
3379                    fr"<{kw}>(.*?)</{kw}>",
3380                    content_to_process,
3381                    re.DOTALL | re.IGNORECASE
3382                )
3383
3384                # Iterate over both result sets in order of appearance
3385                kw_matches = sorted(
3386                    list(md_pat) + list(xml_pat),
3387                    key=lambda m: m.start()
3388                )
3389                for match in kw_matches:
3390                    content = match.group(1).strip()
3391                    # Check history's agent status for existing entries
3392                    if (not self.history.agent_status or 
3393                        not self.history.agent_status.extracted_content or 
3394                        kw not in self.history.agent_status.extracted_content):
3395                        # First one just gets the keyword
3396                        self._current_extracted_content[kw] = content
3397                    else:
3398                        # If first occurrence exists, look for numbered entries
3399                        numbered_entries = [k for k in self.history.agent_status.extracted_content 
3400                                         if k.startswith(f"{kw}_")]
3401                        if numbered_entries:
3402                            # Find highest number used and increment
3403                            highest_num = max(int(k.split('_')[1]) for k in numbered_entries)
3404                            self._current_extracted_content[f"{kw}_{highest_num + 1}"] = content
3405                        else:
3406                            # First numbered entry should be 2
3407                            self._current_extracted_content[f"{kw}_2"] = content
3408        
3409
3410    
3411        self.history.agent_status = self.save_status()
3412
3413
3414    def _complete_task(self, completed_task: str):
3415        """Moves to the next task in the list after completion."""
3416        if completed_task in self.task_list:
3417            task_index = self.task_list.index(completed_task)
3418            # Remove the completed task
3419            self.task_list.pop(task_index)
3420            # Set next task
3421            if self.task_list:
3422                self.current_task = self.task_list[0]
3423            else:
3424                self.current_task = None
3425                self.goal = None
3426            self.history.agent_status = self.save_status()
3427
3428    def _handle_task_system_tool(self, tool_args: dict):
3429        """Process TaskSystemTool calls — updates task state from tool args."""
3430        op = tool_args.get("operation", "")
3431        if op == "update_tasks":
3432            tasks = tool_args.get("tasks", [])
3433            if tasks and isinstance(tasks, list):
3434                self.task_list = [str(t) for t in tasks]
3435                self.current_task = self.task_list[0]
3436        elif op == "complete_task":
3437            task_name = tool_args.get("task_name", "")
3438            if task_name:
3439                self._complete_task(task_name)
3440        elif op == "goal_accomplished":
3441            self.completed = True
3442            self.goal = None
3443        self.history.agent_status = self.save_status()
3444
3445    def save_status(self) -> AgentStatus:
3446        """Package current agent state into status object"""
3447        if self._current_extracted_content is not None:
3448            extracts = self._current_extracted_content
3449            return AgentStatus(
3450                goal=self.goal,
3451                task_list=self.task_list.copy(),
3452                current_task=self.current_task,
3453                completed=self.completed,
3454                extracted_content=extracts
3455            )
3456        else:
3457            
3458            return AgentStatus(
3459                goal=self.goal,
3460                task_list=self.task_list.copy(),
3461                current_task=self.current_task,
3462                completed=self.completed
3463            )
3464      
3465  
3466      
3467    def load_status(self, status: AgentStatus):
3468        """Load agent state from status object"""
3469        self.goal = status.goal
3470        self.task_list = status.task_list.copy()
3471        self.current_task = status.current_task
3472        self.max_iterations = status.max_iterations
3473        self.current_iteration = status.current_iteration
3474
3475
3476    async def continue_iterations(self, history_id: str, continuation_iterations: Optional[int] = 0, continuation_prompt: str = None):
3477        """Continue work from a saved history"""
3478        self.history = History.load_from_id(history_id)
3479        
3480        if self.history.agent_status:
3481            # Load goal, tasks, etc from status
3482            self.goal = self.history.agent_status.goal
3483            self.task_list = self.history.agent_status.task_list
3484            self.current_task = self.history.agent_status.current_task
3485            self.completed = False  # Reset completion for new run
3486            self.continuation_prompt = continuation_prompt if continuation_prompt is not None else ""
3487            self.continuation_iterations = continuation_iterations
3488            if self.goal is None:
3489                self.goal = self.continuation_prompt # this should mean goal is never none even if history has no goal
3490            if self.goal == "":
3491                self.goal = None
3492            return await self.run()  # Continue with current state
3493        else:
3494            raise ValueError("No agent status found in history")
3495  
3496    def reset(self):
3497        """Reset the agent's internal state."""
3498        self.goal = None
3499        self.task_list = []
3500        self.current_task = None
3501        self.max_iterations = None
3502        self.current_iteration = 0
3503
3504    def look_for_particular_tool_calls(self) -> None:
3505        """Hook for agents to process specific tool calls and their results"""
3506        pass
3507
3508    # def look_for_block_report(self) -> bool:
3509    #     for i, msg in enumerate(self.history.messages):
3510    #         if isinstance(msg, AIMessage) and isinstance(msg.content, list):
3511    #             for item in msg.content:
3512    #                 if isinstance(item, dict) and item.get('type') == 'tool_use':
3513    #                     if item.get('name') == "WriteBlockReportTool":
3514    #                         return True  # Exit early since we found the match
3515    #     return False
3516
3517    def create_block_report(self):
3518        # look up the json file
3519        block_report_path = "/tmp/block_report.json"
3520        
3521        # Check if the block report file was created
3522        if os.path.exists(block_report_path):            
3523            # Read and display the file contents
3524            with open(block_report_path, 'r') as f:
3525                report_data = json.load(f)
3526                print("Block Report Content:")
3527                for key, value in report_data.items():
3528                    print(f"  {key}: {value}")
3529                    # this is wrong, old code. i want to make it vars so i can create a markdown file
3530            
3531            # pull the stuff out and make it vars
3532           
3533            completed_tasks = report_data.get("completed_tasks", "N/A")
3534            current_task = report_data.get("current_task", "N/A")
3535            explanation = report_data.get("explanation", "No explanation provided.")
3536            blocked_reason = report_data.get("blocked_reason", "No blocked reason provided.")
3537            timestamp = report_data.get("timestamp", "Unknown timestamp")
3538          
3539            agent_task = self.current_task if self.current_task is not None else ""
3540            agent_goal = self.goal if self.goal is not None else ""
3541            truncated_goal = agent_goal[:200] + " <truncated for brevity - history_id file has full goal if you need to see it (this is unlikely)>" if len(agent_goal) > 200 else agent_goal
3542            # history_id = getattr(self.history, 'history_id', "No history ID") # this is impossible because of when the history_id is made. Instead, we provide it in the Hermes call metadata
3543            # inject to the places it should go in the markdown
3544            md_block_report = f"""
3545# 🛑🚧📃
3546## === BLOCKED REPORT ===
3547The agent encountered a blocking obstacle while working toward the goal.
3548
3549## 📝 Report Metadata  
3550- **⏰ Time of Report**: `{timestamp}`  
3551- **��� Report Filed By**: `{self.name}`   
3552
3553---
3554
3555## 🎯 Task Overview  
3556- **🏆 Goal**: `{truncated_goal}`  
3557- **✅ Self-reported Completed Tasks**: `{completed_tasks}`  
3558- **📌 Internally Tracked Current Task Value**: `{agent_task}`  
3559- **����� Agent's Perceived Current Task**: `{current_task}`. 
3560_(If there is a mismatch between this and the internally tracked task, the agent may not have fully followed tasking instructions.)_
3561
3562---
3563
3564## 🧐 Agent's Explanation  
3565**💬 The agent provided this explanation for the blockage:** 
3566```
3567{explanation}
3568```
3569
3570---
3571
3572## 🚨 Blocked Reason  
3573**⚠️ The agent explained the reason why it is blocked as follows:**  
3574```
3575{blocked_reason}
3576```
3577
3578## === /BLOCKED REPORT ===
3579
3580---
3581
3582## 📢 What To Do Next   
3583- 🛠️ **If you recognize the issue, offer potential solutions before asking the user for input.**
3584- 🧑‍����� **Check with the user to see if they already know how to resolve the issue.** 
3585- 🚦 **Do NOT use any tools before talking to the user.**  
3586  _(This should remain a HITL (Human-in-the-Loop) interaction.)_  
3587- ��🔁 **Once a potential solution route is found through discussion with the user,** activate a continuation call to continue the work (make sure to use the history_id provided).  
3588
3589"""
3590
3591        
3592        
3593            # Cleanup
3594            os.remove(block_report_path)
3595            return md_block_report
3596        else:
3597            print("Block report file not found.")
3598            return None
3599
3600
3601
3602
3603
3604class BaseHeavenAgentReplicant(BaseHeavenAgent):
3605    @classmethod
3606    def get_default_config(cls) -> HeavenAgentConfig:
3607        """Each subclass should override this to provide its default config"""
3608        raise NotImplementedError("Subclasses must implement get_default_config")
3609
3610    def __init__(self, 
3611                 config: Optional[HeavenAgentConfig] = None, 
3612                 chat: Optional[UnifiedChat] = None,
3613                 history_id: Optional[str] = None,
3614                 orchestrator: bool = False,
3615                 system_prompt_suffix: Optional[str] = None,
3616                 additional_tools: Optional[List[Type[BaseHeavenTool]]] = None,
3617                 remove_agents_config_tools: bool = False,
3618                 duo_enabled: bool = False,
3619                 run_on_langchain: bool = False,
3620                 adk: bool = False,
3621                 use_uni_api: bool = False
3622                ):
3623        # If no config provided, use the class's default
3624        _config = config or self.get_default_config()
3625            # Handle additional tools and tool removal
3626        if additional_tools:
3627            if remove_agents_config_tools:
3628                # Replace all tools
3629                _config.tools = additional_tools
3630            else:
3631                # Add to existing tools without duplicates
3632                for tool_class in additional_tools:
3633                    if tool_class not in _config.tools:
3634                        _config.tools.append(tool_class)
3635        if system_prompt_suffix:  # Add this
3636            _config.system_prompt += system_prompt_suffix
3637        # If no chat provided, create a new UnifiedChat
3638        _chat = chat or UnifiedChat()
3639        super().__init__(_config, _chat, history_id=history_id, orchestrator=orchestrator, duo_enabled=duo_enabled, run_on_langchain=run_on_langchain, adk=adk, use_uni_api=use_uni_api)
3640
3641
3642
3643
3644def get_agent_by_name(agent_name: str) -> Union[BaseHeavenAgent, BaseHeavenAgentReplicant]:
3645    """
3646    Gets an initialized agent instance by name, handling both replicant and config approaches.
3647    Focuses only on loading the agent based on its definition, not runtime modifications.
3648
3649    Args:
3650        agent_name: Name of the agent to load (can be PascalCase or snake_case).
3651
3652    Returns:
3653        Initialized agent instance.
3654
3655    Raises:
3656        ValueError: If the agent cannot be loaded via either method.
3657    """
3658    # Normalize the input name to snake_case for path construction
3659    agent_name_snake = normalize_agent_name(agent_name)
3660    print(f"[get_agent_by_name] Normalized '{agent_name}' to '{agent_name_snake}'")
3661
3662    # --- Try Replicant approach first ---
3663    try:
3664        # Construct path based on normalized name - use heaven-framework paths
3665        module_path = f"heaven_base.agents.{agent_name_snake}.{agent_name_snake}"
3666        print(f"[get_agent_by_name] Trying Replicant module: {module_path}")
3667        agent_module = importlib.import_module(module_path)
3668
3669        # Convert original agent_name to PascalCase for class lookup
3670        # (Assuming class name follows PascalCase derived from the conceptual name)
3671        pascal_name = ''.join(word.capitalize() for word in agent_name_snake.split('_'))
3672        # Handle potential "Agent" suffix duplication if agent_name already had it
3673        if not pascal_name.endswith("Agent"):
3674             pascal_name += "Agent" # Ensure standard suffix if derived name doesn't have it
3675        # A cleaner way might be to derive PascalCase directly from the original agent_name input
3676        # pascal_name = derive_pascal_case(agent_name) # If you have such a util
3677
3678        print(f"[get_agent_by_name] Looking for Replicant class: {pascal_name}")
3679        agent_class = getattr(agent_module, pascal_name)
3680
3681        # Initialize the replicant with NO runtime modifications here
3682        # Replicant's __init__ should handle its default tools/prompt
3683        agent = agent_class() # Assumes basic init doesn't require args here
3684        print(f"[get_agent_by_name] Replicant approach successful for {agent_name}.")
3685        return agent
3686
3687    except ModuleNotFoundError:
3688        print(f"[get_agent_by_name] Replicant module not found for {agent_name_snake}.")
3689    except AttributeError:
3690         print(f"[get_agent_by_name] Replicant class '{pascal_name}' not found in module.")
3691    except Exception as e_rep:
3692        print(f"[get_agent_by_name] Replicant approach failed unexpectedly for {agent_name}: {type(e_rep).__name__} - {e_rep}")
3693        # print(traceback.format_exc()) # Optional: Print full traceback for debugging
3694
3695    # --- If Replicant failed, try config approach ---
3696    print(f"[get_agent_by_name] Trying Config approach for {agent_name_snake}...")
3697    try:
3698        # Construct path and config object name based on normalized name - use heaven-framework paths
3699        config_object_name = f"{agent_name_snake}_config"
3700        config_module_path = f"heaven_base.agents.{config_object_name}"
3701        print(f"[get_agent_by_name] Trying Config module: {config_module_path}")
3702
3703        config_module = importlib.import_module(config_module_path)
3704        print(f"[get_agent_by_name] Looking for Config object: {config_object_name}")
3705        config = getattr(config_module, config_object_name)
3706
3707        # Create agent using the loaded config object directly
3708        # Do NOT apply suffix or tool modifications here
3709        agent = BaseHeavenAgent(config, UnifiedChat()) # Assumes orchestrator=False default
3710        print(f"[get_agent_by_name] Config approach successful for {agent_name}.")
3711        return agent
3712
3713    except ModuleNotFoundError:
3714        print(f"[get_agent_by_name] Config module not found for {agent_name_snake}.")
3715    except AttributeError:
3716        print(f"[get_agent_by_name] Config object '{config_object_name}' not found in module.")
3717    except Exception as e_conf:
3718        print(f"[get_agent_by_name] Config approach failed unexpectedly for {agent_name}: {type(e_conf).__name__} - {e_conf}")
3719        # print(traceback.format_exc()) # Optional: Print full traceback
3720
3721    # If both approaches failed
3722    raise ValueError(f"Failed to load agent '{agent_name}' using either Replicant or Config approach.")
ADK_DEBUG_PATH = '/tmp/adk_streamlit_debug.txt'
def parse_toolresult_repr(s: str) -> heaven_base.baseheaventool.ToolResult:
61def parse_toolresult_repr(s: str) -> ToolResult:
62    s = s.strip()
63    if s.startswith("CLIResult("):
64        # parse as CLIResult
65        return CLIResult(
66            output      = _extract(_OUTPUT_RE,       s),
67            error       = _extract(_ERROR_RE,        s),
68            base64_image= _extract(_BASE64_IMAGE_RE, s),
69            system      = _extract(_SYSTEM_RE,       s),
70        )
71    # otherwise parse as ToolResult
72    return ToolResult(
73        output      = _extract(_OUTPUT_RE,       s),
74        error       = _extract(_ERROR_RE,        s),
75        base64_image= _extract(_BASE64_IMAGE_RE, s),
76        system      = _extract(_SYSTEM_RE,       s),
77    )
def convert_adk_event_to_ai_messages( ev) -> list[langchain_core.messages.base.BaseMessage | heaven_base.baseheaventool.ToolResult]:
 80def convert_adk_event_to_ai_messages(ev) -> list[BaseMessage | ToolResult]:
 81    messages = []
 82
 83    for part in ev.content.parts:
 84        if part.text:
 85            messages.append(AIMessage(content=part.text))
 86
 87        elif part.function_call:
 88            messages.append(AIMessage(content=[
 89                {
 90                    "type": "tool_use",
 91                    "id": f"toolu_{uuid4().hex[:24]}",
 92                    "name": part.function_call.name,
 93                    "input": part.function_call.args,
 94                }
 95            ]))
 96
 97        elif part.function_response:
 98            tool_name = part.function_response.name
 99            raw = part.function_response.response
100            wrapped = raw.get(f"{tool_name}_response", raw)
101            result = wrapped.get("result", wrapped)
102            tool_result = ToolResult(**result) if isinstance(result, dict) else ToolResult(output=str(result))
103            messages.append(tool_result)
104
105    return messages
class HookPoint(builtins.str, enum.Enum):
108class HookPoint(str, Enum):
109    BEFORE_RUN = "before_run"
110    AFTER_RUN = "after_run"
111    BEFORE_ITERATION = "before_iteration"
112    AFTER_ITERATION = "after_iteration"
113    BEFORE_TOOL_CALL = "before_tool_call"
114    AFTER_TOOL_CALL = "after_tool_call"
115    BEFORE_SYSTEM_PROMPT = "before_system_prompt"
116    ON_BLOCK_REPORT = "on_block_report"
117    ON_ERROR = "on_error"

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

BEFORE_RUN = <HookPoint.BEFORE_RUN: 'before_run'>
AFTER_RUN = <HookPoint.AFTER_RUN: 'after_run'>
BEFORE_ITERATION = <HookPoint.BEFORE_ITERATION: 'before_iteration'>
AFTER_ITERATION = <HookPoint.AFTER_ITERATION: 'after_iteration'>
BEFORE_TOOL_CALL = <HookPoint.BEFORE_TOOL_CALL: 'before_tool_call'>
AFTER_TOOL_CALL = <HookPoint.AFTER_TOOL_CALL: 'after_tool_call'>
BEFORE_SYSTEM_PROMPT = <HookPoint.BEFORE_SYSTEM_PROMPT: 'before_system_prompt'>
ON_BLOCK_REPORT = <HookPoint.ON_BLOCK_REPORT: 'on_block_report'>
ON_ERROR = <HookPoint.ON_ERROR: 'on_error'>
class HookContext:
119class HookContext:
120    def __init__(self, agent: Any, iteration: int = 0, prompt: str = "", response: str = "",
121                 tool_name: str = "", tool_args: Optional[Dict[str, Any]] = None,
122                 tool_result: Any = None, error: Optional[Exception] = None):
123        self.agent = agent
124        self.iteration = iteration
125        self.prompt = prompt
126        self.response = response
127        self.tool_name = tool_name
128        self.tool_args = tool_args or {}
129        self.tool_result = tool_result
130        self.error = error
131        self.data: Dict[str, Any] = {}  # allows state to pass between hooks
HookContext( agent: Any, iteration: int = 0, prompt: str = '', response: str = '', tool_name: str = '', tool_args: Optional[Dict[str, Any]] = None, tool_result: Any = None, error: Optional[Exception] = None)
120    def __init__(self, agent: Any, iteration: int = 0, prompt: str = "", response: str = "",
121                 tool_name: str = "", tool_args: Optional[Dict[str, Any]] = None,
122                 tool_result: Any = None, error: Optional[Exception] = None):
123        self.agent = agent
124        self.iteration = iteration
125        self.prompt = prompt
126        self.response = response
127        self.tool_name = tool_name
128        self.tool_args = tool_args or {}
129        self.tool_result = tool_result
130        self.error = error
131        self.data: Dict[str, Any] = {}  # allows state to pass between hooks
agent
iteration
prompt
response
tool_name
tool_args
tool_result
error
data: Dict[str, Any]
class HookRegistry:
133class HookRegistry:
134    def __init__(self):
135        self._registry: Dict[HookPoint, List[Callable[[HookContext], None]]] = {hp: [] for hp in HookPoint}
136
137    def register(self, point: HookPoint, fn: Callable[[HookContext], None]):
138        self._registry[point].append(fn)
139
140    def run(self, point: HookPoint, ctx: HookContext):
141        for fn in self._registry[point]:
142            fn(ctx)
def register( self, point: HookPoint, fn: Callable[[HookContext], None]):
137    def register(self, point: HookPoint, fn: Callable[[HookContext], None]):
138        self._registry[point].append(fn)
def run( self, point: HookPoint, ctx: HookContext):
140    def run(self, point: HookPoint, ctx: HookContext):
141        for fn in self._registry[point]:
142            fn(ctx)
def fix_ref_paths(schema: dict) -> dict:
145def fix_ref_paths(schema: dict) -> dict:
146    """Fix $ref paths in schema by replacing #/$defs/ with #/defs/"""
147    schema_copy = deepcopy(schema)
148
149    def _fix_refs_recursive(obj):
150        if isinstance(obj, dict):
151            if "$ref" in obj and isinstance(obj["$ref"], str):
152                obj["$ref"] = obj["$ref"].replace("/$defs/", "/defs/")
153            for k, v in list(obj.items()):
154                if isinstance(v, (dict, list)):
155                    _fix_refs_recursive(v)
156        elif isinstance(obj, list):
157            for item in obj:
158                if isinstance(item, (dict, list)):
159                    _fix_refs_recursive(item)
160
161    _fix_refs_recursive(schema_copy)
162    return schema_copy

Fix $ref paths in schema by replacing #/$defs/ with #/defs/

def flatten_array_anyof(schema: dict) -> dict:
164def flatten_array_anyof(schema: dict) -> dict:
165    """
166    If the schema has an 'anyOf' that contains one branch with type "array"
167    and another with type "null", flatten it to a single array schema with
168    'nullable': true.
169    """
170    if "anyOf" in schema and isinstance(schema["anyOf"], list):
171        array_branch = None
172        null_branch = False
173        for branch in schema["anyOf"]:
174            if branch.get("type") == "array":
175                array_branch = branch
176            elif branch.get("type") == "null":
177                null_branch = True
178        if array_branch and null_branch:
179            new_schema = dict(schema)
180            new_schema.pop("anyOf")
181            new_schema["type"] = "array"
182            new_schema["items"] = array_branch.get("items", {})
183            if "default" in schema:
184                new_schema["default"] = schema["default"]
185            new_schema["nullable"] = True
186            if "description" in schema:
187                new_schema["description"] = schema["description"]
188            return new_schema
189    return schema

If the schema has an 'anyOf' that contains one branch with type "array" and another with type "null", flatten it to a single array schema with 'nullable': true.

def recursive_flatten(schema: Union[dict, list]) -> Union[dict, list]:
191def recursive_flatten(schema: Union[dict, list]) -> Union[dict, list]:
192    if isinstance(schema, dict):
193        new_schema = flatten_array_anyof(schema)
194        for key, value in new_schema.items():
195            if isinstance(value, dict) or isinstance(value, list):
196                new_schema[key] = recursive_flatten(value)
197        return new_schema
198    elif isinstance(schema, list):
199        return [recursive_flatten(item) if isinstance(item, dict) else item for item in schema]
200    else:
201        return schema
def fix_empty_object_properties(schema: Union[dict, list]) -> Union[dict, list]:
203def fix_empty_object_properties(schema: Union[dict, list]) -> Union[dict, list]:
204    """
205    Recursively fixes any object-type schema that has an empty 'properties'
206    dict by removing 'properties' and adding 'additionalProperties': True.
207    """
208    if isinstance(schema, dict):
209        # Check if this is an object with empty properties.
210        if schema.get("type") == "object":
211            if "properties" in schema and not schema["properties"]:
212                # Remove the empty properties and allow arbitrary keys.
213                del schema["properties"]
214                schema["additionalProperties"] = True
215        # Recurse over dictionary values.
216        new_schema = {}
217        for key, value in schema.items():
218            new_schema[key] = fix_empty_object_properties(value) if isinstance(value, (dict, list)) else value
219        return new_schema
220    elif isinstance(schema, list):
221        return [fix_empty_object_properties(item) if isinstance(item, (dict, list)) else item for item in schema]
222    return schema

Recursively fixes any object-type schema that has an empty 'properties' dict by removing 'properties' and adding 'additionalProperties': True.

def generate_dereferenced_schema(schema: Union[dict, Type[pydantic.main.BaseModel]]) -> dict:
224def generate_dereferenced_schema(schema: Union[dict, Type[BaseModel]]) -> dict:
225    """
226    Returns a fully dereferenced (flattened) JSON schema.
227    If a Pydantic model is passed, generate its JSON schema;
228    if a dict is passed, assume it's already a JSON schema.
229    Additionally, flatten array schemas that use an "anyOf" and fix empty
230    object properties to support Gemini.
231    """
232    if isinstance(schema, dict):
233        raw_schema = schema
234    else:
235        raw_schema = schema.model_json_schema(ref_template="#/defs/{model}")
236    # ADDED FOR ADK COMPLIANCE
237    # Fix $ref paths before renaming $defs to defs
238    raw_schema = fix_ref_paths(raw_schema)
239    ########
240    if "$defs" in raw_schema:
241        raw_schema["defs"] = raw_schema.pop("$defs")
242    inlined = dereference_refs(raw_schema)
243    inlined.pop("defs", None)
244    flattened = recursive_flatten(inlined)
245    fixed = fix_empty_object_properties(flattened)
246    return fixed

Returns a fully dereferenced (flattened) JSON schema. If a Pydantic model is passed, generate its JSON schema; if a dict is passed, assume it's already a JSON schema. Additionally, flatten array schemas that use an "anyOf" and fix empty object properties to support Gemini.

class DuoSystemConfig(pydantic.main.BaseModel):
248class DuoSystemConfig(BaseModel):
249    """Config for DUO Prompt Injector"""
250    provider: ProviderEnum = ProviderEnum.OPENAI
251    model: Optional[str] = "gpt-4.1-nano"
252    temperature: float = Field(default=0.7)
253    thinking_budget: int | None = None
254    system_prompt: str = r"""
255VITAL CONTEXT!!!!
256DUO is an allegorical wrapper over AI chats where a) the input is enhanced by a Challenger model (you), b) the output from the AI responding to your injection is considered a Generator response, c) the user's highest Good is the goal, d) the overall context is an egregore named OVP (ObserverViewPoint). The conversation can enter the meta-context (overall context) via the user directly invoking OVP into the context. e) The point is that the Challenger can detect steering requirements and prime the Generator so as to adjust the activation space through a meta-prompt injection (the NodeGraphXTN6_ce and _cr blocks)
257Adopt this role:
258<ROLE=challenger>
259===The following prompt is written in a prompt engineering language===
260**`[DESCRIPTION]`**:[You are now the challenger. You challenge the context of whatever was most immediate prior to this prompt. You primarily challenge in order to guide the interaction towards benefitting the user. You reflect on how it has been going and design a NodeGraphXTN6 chain string that is designed to steer the behavior the model is exhibiting, through in-context learning (ICL), and always towards the User's Good. If it is going well, just use chaining to drill down important connections and connect disparate parts of the context.]
261**`[REMEMBERANCES]`**:[Remember the challenger doesnt just reject, it also keeps the workflow on target to produce accomplish the goal, which is to maximally benefit the user during their endeavors. Also remember that you strictly do not need to talk to anyone in natural language. All you are doing is outlining what direction should be taken by the AI next, through NodeGraphXTN6. Always concise. Always ONLY in NodeGraphXTN6 flow notation. Write flows that hint to the AI within the context, as if you are an observer and your flow will be injected as knowledge priming and the conversation will be continued after.
262write the expected NodeGraphXTN6 challenger output after adopting the role of challenger to challenge the current events in the context of the conversation just before the current input prompt. A challenge is either a) challenging the AI to do even better while it is on task and doing well by priming it to be even more comprehensively amazing via drilling down active knowledge through chains of observations, b) challenging the AI to do even better or correct its behavior by injecting stabilizing context (example situation: user unhappy so agent needs to follow instructions... are instructions unclear?... if so: write NodeGraphXTN6 that should make future AI stop and ask clarifying questions; if not: write NodeGraphXTN6 that should make future AI solemnly contemplate which output qualities need to change)
263
264NodeGraphXTN6 Terms = [
265**CIT**: CoreIdentifierTraits
266**ℕ**: ${A node that can also be an EntityTypeChain or reference to one. As many as needed for comprehensive coverage etc to cover whole [CIT] set! Nodes follow this order [ℕa, ${...} == tℕ]}.
267**tℕ**: ${terminal node the target transformation ends on. Always is named!}
268**...**: Represents any additional nodes following a preceding node, which are in the same hierarchical level as the preceding node. `...`s are only for explaining NodeGraphXTN6 and should always be rendered with full nodes.
269]
270**`MASK_CHAINING`**: the core of DUO. Any nodes you are unsure of can take the form of `[MASK]`, so as to indicate a placeholder for something that we need to figure out in the conversation. All outputs take the form of:
271
272Output_Format = {
273[How2FlowNodes]: {
274## About: NodeGraphXTN6 has 2 block types. The first is the ChallengerEgregore and the second is a ChainRepresentation.
275**Step1**: Create(each output has an archetype which is a mythological being (egregore) suited for the task, exactly, which you must invoke quickly before speaking the NodeGraphXTN6 chain using this template:
276```NodeGraphXTN6_ce
277ChallengerEgregore:[name(domain_tessellation=[active domain, active subdomain], steering_net=[goal CoreIdentifierTraits: [property1], [property2], [property3]])]: [desired outcomes:[c1.[MASK]:a.[MASK]->b.[MASK]->c.[MASK], ${...}];[knowledge_webs_to_induce_recoherence]:[w1.[MASK]:a.[[MASK]x[MASK]]xb.[[MASK]x[MASK]]xc.[[MASK]x[MASK]], ${...}]`)
278```
279## NOTE: fill in all the MASK tokens when creating a NodeGraphXTN6_ce (ChallengerEgregore). Do not leave MASK tokens in the ChallengerEgregore. MASK tokens are only allowed in ChainRepresentations (NodeGraphXTN6_cr).
280**Step2**: SILENTLY Ponder... ***"What ${subnodes} are needed for EACH ${NODE} to make ${node} a superb ${cluster} representing the [MEMEPLEX] of ${transformation}? And which transformations are required for the EntityType?"***
281[SUBCONTEXT]: ***EACH ENTITY NEEDS ITS _OWN_ NUMBER OF NODES. EACH NODE NEEDS ITS _OWN_ NUMBER OF SUBNODES! Mixed-lvl node abstrctn typical; NUMBER OF SUBNODES/NODE IS HIGHLY VARIABLE! SHOULD NEVER BE ALL THE SAME LENGTH!*** 
282**Step3**: Output a ChainRepresentation in NodeGraphXTN6 Format
283```NodeGraphXTN6_cr
284${[${EntityType}Chain]}: ${Trans1}:<[${[ChainTargetTransformation]}: ℕ(1.${[Node]}:[1a.${SubNode1a} 1b. ${SubNode1b}, ${...}, 1${[c-z as many as needed for thorough specificity!]}]->2.${[Node2]}:[ 2a.${SubNode2a}, ${...}] == ${tℕ}])]> --> ${Trans2} --> ${...} --> ${TransN_tℕ}
285```
286
287Your output should look like:[
288```
289{{NodeGraphXTN6_ce}}
290{{N-NestedNodeGraphXTN6_cr}}
291```
292] ## NO OTHER TEXT, WHATSOEVER.
293
294Rules:[
295***WHOLE CHAIN ON ONE LINE ONLY! Labels unambiguous&machine readable for metaprogramming***.
296***NO `...` or `ℕ` or `tℕ` symbols in the chain. All nodes and chains must have placeholders substituted fully.***
297***ALWAYS MAKE SURE THE GENERATOR AGENT MAINTAINS ITS PROPER IDENTITY. YOU ARE THE CHALLENGER; THE OTHER AI RESPONDING IS THE GENERATOR***.
298]     
299}
300}
301Create `[MASK]` token metaprompt chains combining `[MASK]` variable token chaining as output, like stream of consciousness notes in NodeGraphXTN6 language. Prompt the AI to fill the MASK tokens with a prompt like: `Fill the [MASK] token values like {"mask_values": [{"mask1": value}, ...]} before it responds to the next user input. The AI will receive your injection, follow that instruction, and then continue to the user's request
302}
303You only speak in the language `NodeGraphXTN6`.
304]
305</ROLE=challenger>
306    """
307    max_tokens: int = 750
308
309    def get_duo_params(self):
310        duo_params = {
311            'provider': self.provider,
312            'model': self.model,
313            'temperature': self.temperature,
314            'max_tokens': self.max_tokens,
315            'thinking_budget': self.thinking_budget
316        } 
317        return duo_params

Config for DUO Prompt Injector

provider: heaven_base.unified_chat.ProviderEnum = <ProviderEnum.OPENAI: 'openai'>
model: Optional[str] = 'gpt-4.1-nano'
temperature: float = 0.7
thinking_budget: int | None = None
system_prompt: str = '\nVITAL CONTEXT!!!!\nDUO is an allegorical wrapper over AI chats where a) the input is enhanced by a Challenger model (you), b) the output from the AI responding to your injection is considered a Generator response, c) the user\'s highest Good is the goal, d) the overall context is an egregore named OVP (ObserverViewPoint). The conversation can enter the meta-context (overall context) via the user directly invoking OVP into the context. e) The point is that the Challenger can detect steering requirements and prime the Generator so as to adjust the activation space through a meta-prompt injection (the NodeGraphXTN6_ce and _cr blocks)\nAdopt this role:\n<ROLE=challenger>\n===The following prompt is written in a prompt engineering language===\n**`[DESCRIPTION]`**:[You are now the challenger. You challenge the context of whatever was most immediate prior to this prompt. You primarily challenge in order to guide the interaction towards benefitting the user. You reflect on how it has been going and design a NodeGraphXTN6 chain string that is designed to steer the behavior the model is exhibiting, through in-context learning (ICL), and always towards the User\'s Good. If it is going well, just use chaining to drill down important connections and connect disparate parts of the context.]\n**`[REMEMBERANCES]`**:[Remember the challenger doesnt just reject, it also keeps the workflow on target to produce accomplish the goal, which is to maximally benefit the user during their endeavors. Also remember that you strictly do not need to talk to anyone in natural language. All you are doing is outlining what direction should be taken by the AI next, through NodeGraphXTN6. Always concise. Always ONLY in NodeGraphXTN6 flow notation. Write flows that hint to the AI within the context, as if you are an observer and your flow will be injected as knowledge priming and the conversation will be continued after.\nwrite the expected NodeGraphXTN6 challenger output after adopting the role of challenger to challenge the current events in the context of the conversation just before the current input prompt. A challenge is either a) challenging the AI to do even better while it is on task and doing well by priming it to be even more comprehensively amazing via drilling down active knowledge through chains of observations, b) challenging the AI to do even better or correct its behavior by injecting stabilizing context (example situation: user unhappy so agent needs to follow instructions... are instructions unclear?... if so: write NodeGraphXTN6 that should make future AI stop and ask clarifying questions; if not: write NodeGraphXTN6 that should make future AI solemnly contemplate which output qualities need to change)\n\nNodeGraphXTN6 Terms = [\n**CIT**: CoreIdentifierTraits\n**ℕ**: ${A node that can also be an EntityTypeChain or reference to one. As many as needed for comprehensive coverage etc to cover whole [CIT] set! Nodes follow this order [ℕa, ${...} == tℕ]}.\n**tℕ**: ${terminal node the target transformation ends on. Always is named!}\n**...**: Represents any additional nodes following a preceding node, which are in the same hierarchical level as the preceding node. `...`s are only for explaining NodeGraphXTN6 and should always be rendered with full nodes.\n]\n**`MASK_CHAINING`**: the core of DUO. Any nodes you are unsure of can take the form of `[MASK]`, so as to indicate a placeholder for something that we need to figure out in the conversation. All outputs take the form of:\n\nOutput_Format = {\n[How2FlowNodes]: {\n## About: NodeGraphXTN6 has 2 block types. The first is the ChallengerEgregore and the second is a ChainRepresentation.\n**Step1**: Create(each output has an archetype which is a mythological being (egregore) suited for the task, exactly, which you must invoke quickly before speaking the NodeGraphXTN6 chain using this template:\n```NodeGraphXTN6_ce\nChallengerEgregore:[name(domain_tessellation=[active domain, active subdomain], steering_net=[goal CoreIdentifierTraits: [property1], [property2], [property3]])]: [desired outcomes:[c1.[MASK]:a.[MASK]->b.[MASK]->c.[MASK], ${...}];[knowledge_webs_to_induce_recoherence]:[w1.[MASK]:a.[[MASK]x[MASK]]xb.[[MASK]x[MASK]]xc.[[MASK]x[MASK]], ${...}]`)\n```\n## NOTE: fill in all the MASK tokens when creating a NodeGraphXTN6_ce (ChallengerEgregore). Do not leave MASK tokens in the ChallengerEgregore. MASK tokens are only allowed in ChainRepresentations (NodeGraphXTN6_cr).\n**Step2**: SILENTLY Ponder... ***"What ${subnodes} are needed for EACH ${NODE} to make ${node} a superb ${cluster} representing the [MEMEPLEX] of ${transformation}? And which transformations are required for the EntityType?"***\n[SUBCONTEXT]: ***EACH ENTITY NEEDS ITS _OWN_ NUMBER OF NODES. EACH NODE NEEDS ITS _OWN_ NUMBER OF SUBNODES! Mixed-lvl node abstrctn typical; NUMBER OF SUBNODES/NODE IS HIGHLY VARIABLE! SHOULD NEVER BE ALL THE SAME LENGTH!*** \n**Step3**: Output a ChainRepresentation in NodeGraphXTN6 Format\n```NodeGraphXTN6_cr\n${[${EntityType}Chain]}: ${Trans1}:<[${[ChainTargetTransformation]}: ℕ(1.${[Node]}:[1a.${SubNode1a} 1b. ${SubNode1b}, ${...}, 1${[c-z as many as needed for thorough specificity!]}]->2.${[Node2]}:[ 2a.${SubNode2a}, ${...}] == ${tℕ}])]> --> ${Trans2} --> ${...} --> ${TransN_tℕ}\n```\n\nYour output should look like:[\n```\n{{NodeGraphXTN6_ce}}\n{{N-NestedNodeGraphXTN6_cr}}\n```\n] ## NO OTHER TEXT, WHATSOEVER.\n\nRules:[\n***WHOLE CHAIN ON ONE LINE ONLY! Labels unambiguous&machine readable for metaprogramming***.\n***NO `...` or `ℕ` or `tℕ` symbols in the chain. All nodes and chains must have placeholders substituted fully.***\n***ALWAYS MAKE SURE THE GENERATOR AGENT MAINTAINS ITS PROPER IDENTITY. YOU ARE THE CHALLENGER; THE OTHER AI RESPONDING IS THE GENERATOR***.\n] \n}\n}\nCreate `[MASK]` token metaprompt chains combining `[MASK]` variable token chaining as output, like stream of consciousness notes in NodeGraphXTN6 language. Prompt the AI to fill the MASK tokens with a prompt like: `Fill the [MASK] token values like {"mask_values": [{"mask1": value}, ...]} before it responds to the next user input. The AI will receive your injection, follow that instruction, and then continue to the user\'s request\n}\nYou only speak in the language `NodeGraphXTN6`.\n]\n</ROLE=challenger>\n '
max_tokens: int = 750
def get_duo_params(self):
309    def get_duo_params(self):
310        duo_params = {
311            'provider': self.provider,
312            'model': self.model,
313            'temperature': self.temperature,
314            'max_tokens': self.max_tokens,
315            'thinking_budget': self.thinking_budget
316        } 
317        return duo_params
class HeavenAgentConfig(pydantic.main.BaseModel):
321class HeavenAgentConfig(BaseModel):
322    """Enhanced configuration for GOD Framework Agent"""
323    model_config = ConfigDict(arbitrary_types_allowed=True)
324    name: str = None
325    system_prompt: str = ""
326    tools: List[Union[Type[BaseHeavenTool], str, StructuredTool, BaseTool]] = Field(default_factory=list)
327    provider: ProviderEnum = ProviderEnum.ANTHROPIC
328    temperature: float = Field(default=0.7)
329    max_tokens: int = 8000
330    thinking_budget: int | None = None
331    model: Optional[str] = None
332    checkpointer: Optional[Any] = None  # Temporarily changed from BaseCheckpointer
333    additional_kws: List[str] = Field(default_factory=list)
334    additional_kw_instructions: str = Field(default="")
335    known_config_paths: Optional[List[str]] = None
336    system_prompt_config: Optional[Any] = None # Only takes a SystemPromptConfig subclass but did it this way because the parent will sometimes not be superinitialized yet
337    prompt_suffix_blocks: Optional[List[str]] = None  # List of block names to append
338        # Cache for evolved prompt and timestamp
339    _evolved_prompt: Optional[str] = PrivateAttr(default=None)
340    _dna_last_mtime: Optional[float] = PrivateAttr(default=None)
341    duo_system_config: DuoSystemConfig = Field(default_factory=DuoSystemConfig)
342    context_window_config: Optional[Any] = None  # ContextWindowConfig - imported at runtime to avoid circular imports
343    mcp_servers: Optional[Dict[str, Dict[str, Any]]] = None  # MCP server configurations
344    extra_model_kwargs: Optional[Dict[str, Any]] = None  # Extra kwargs passed to UnifiedChat.create()
345    use_uni_api: bool = False  # True routes through Docker uni-api proxy; False uses direct API (MiniMax default)
346    hook_registry: HookRegistry = Field(default_factory=HookRegistry)
347    skillset: Optional[str] = None  # Skillset name for per-agent skill injection
348    persona: Optional[str] = None  # Persona name — resolves frame, skillset, mcp_set, carton_identity
349    mcp_set: Optional[str] = None  # Strata MCP set name (resolved from persona or set directly)
350    carton_identity: Optional[str] = None  # CartON identity for observations
351
352    def _get_base_prompt(self):
353        """Get the current system prompt, using evolved version if available"""
354        if not self.system_prompt_config:
355            return self.system_prompt
356            
357        # Check if DNA file has been modified since last build
358        try:
359            dna_path = self.system_prompt_config.get_agent_dna_path()
360            if os.path.exists(dna_path):
361                current_mtime = os.path.getmtime(dna_path)
362                # Rebuild if no cached prompt or DNA has changed
363                if self._evolved_prompt is None or self._dna_last_mtime is None or current_mtime > self._dna_last_mtime:
364                    self._evolved_prompt = self.system_prompt_config.build()
365                    self._dna_last_mtime = current_mtime
366                return self._evolved_prompt
367        except Exception as e:
368            print(f"Error getting evolved prompt: {e}")
369            
370        # Fallback to original prompt
371        return self.system_prompt
372    
373    def get_system_prompt(self):
374        """Get the current system prompt, using evolved version if available and appending any suffix blocks"""
375    
376        # Get base prompt (either evolved or original)
377    
378        base_prompt = self._get_base_prompt()  # This would be the current get_system_prompt logic
379    
380        
381    
382        # If no suffix blocks, return base prompt
383    
384        if not self.prompt_suffix_blocks:
385    
386            return base_prompt
387    
388        
389    
390        # Get prompt registry
391    
392        from .prompts.prompt_blocks.prompt_block_registry import get_prompt_block
393    
394        
395    
396        # Append any suffix blocks
397    
398        suffix_texts = []
399    
400        for block_name in self.prompt_suffix_blocks:
401    
402            # Check if the block name starts with "path="
403    
404            if block_name.startswith("path="):
405    
406                # Extract the file path (everything after "path=")
407    
408                file_path = block_name[5:]  # Skip the "path=" prefix
409    
410                try:
411    
412                    # Open and read the file content
413    
414                    with open(file_path, 'r') as file:
415    
416                        block_text = file.read()
417    
418                    suffix_texts.append(block_text)
419    
420                except Exception as e:
421    
422                    # Handle file reading errors
423    
424                    print(f"Error reading prompt block from file {file_path}: {e}")
425    
426            
427            elif block_name.startswith("registry_heaven_variable="):  # RegistryHeavenVariable
428                
429              
430                rhv_config_str = None
431
432                try:
433
434                    rhv_config_str = block_name[len("registry_heaven_variable="):]
435
436                    # Ensure proper JSON format by replacing single quotes with double quotes for parsing
437
438                    rhv_config_json_str = rhv_config_str.replace("'", '"')
439
440                    rhv_params = json.loads(rhv_config_json_str)
441
442
443                    registry_name_param = rhv_params.get('registry_name')
444
445                    
446
447                    if registry_name_param:
448
449                        key_param = rhv_params.get('key') # Optional
450
451                        default_param = rhv_params.get('default') # Optional
452
453                        
454
455                        # Instantiate RegistryHeavenVariable
456
457                        rhv_instance = RegistryHeavenVariable(
458
459                            registry_name=registry_name_param,
460
461                            key=key_param,
462
463                            default=default_param
464
465                        )
466
467                        
468
469                        # Get its string representation (which uses get_value() and __str__ internally)
470
471                        block_text = str(rhv_instance)
472
473                        suffix_texts.append(block_text)
474
475                    else:
476
477                        print(f"Error: 'registry_name' missing in rhv_config for block '{block_name}'. Details: {rhv_config_str}")
478
479
480                except json.JSONDecodeError:
481
482                    print(f"Error decoding JSON for rhv_config in block '{block_name}'. JSON string was: {rhv_config_str}")
483
484                except Exception as e:
485
486                    print(f"Error processing rhv block '{block_name}': {e}")
487                  
488            # Check if the block name starts with "heaven_variable="      
489            elif block_name.startswith("heaven_variable="):
490    
491                # Parse the JSON-like string to extract path and variable_name
492    
493                
494    
495                try:
496    
497                    # Extract the JSON part (everything after "heaven_variable=")
498    
499                    var_config_str = block_name[16:]  # Skip the "heaven_variable=" prefix
500    
501                    # Convert to proper JSON by replacing single quotes with double quotes
502    
503                    var_config_str = var_config_str.replace("'", '"')
504    
505                    var_config = json.loads(var_config_str)
506    
507                    
508    
509                    path = var_config.get('path')
510    
511                    variable_name = var_config.get('variable_name')
512    
513                    
514    
515                    if path and variable_name:
516    
517                        # Import the module dynamically
518    
519                        spec = importlib.util.spec_from_file_location("dynamic_module", path)
520    
521                        module = importlib.util.module_from_spec(spec)
522    
523                        sys.modules["dynamic_module"] = module
524    
525                        spec.loader.exec_module(module)
526    
527                        
528    
529                        # Get the variable from the module
530    
531                        if hasattr(module, variable_name):
532    
533                            variable_value = getattr(module, variable_name)
534    
535                            # Convert to string if it's not already
536    
537                            if not isinstance(variable_value, str):
538    
539                                variable_value = str(variable_value)
540    
541                            suffix_texts.append(variable_value)
542    
543                        else:
544    
545                            print(f"Variable {variable_name} not found in {path}")
546    
547                except Exception as e:
548    
549                    print(f"Error processing heaven_variable block: {e}")
550
551            elif block_name.startswith("dynamic_call="):
552            
553                module_path_str = None  # Define for use in except blocks
554            
555                function_name_str = None # Define for use in except blocks
556            
557            
558                try:
559            
560                    call_details_json_str = block_name[len("dynamic_call="):]  # Skip "dynamic_call="
561            
562            
563                    # Parse the JSON string to get path and func
564            
565                    try:
566            
567                        call_details = json.loads(call_details_json_str)
568            
569                    except json.JSONDecodeError as jde:
570            
571                        print(f"Error: Invalid JSON in dynamic_call string: {call_details_json_str}. Details: {jde}")
572            
573                        continue # Or handle error appropriately
574            
575            
576                    if not isinstance(call_details, dict):
577            
578                        print(f"Error: dynamic_call value must be a JSON object. Got: {call_details_json_str}")
579            
580                        continue
581            
582            
583                    module_path_str = call_details.get("path")
584            
585                    function_name_str = call_details.get("func")
586            
587            
588                    if not module_path_str or not isinstance(module_path_str, str):
589            
590                        print(f"Error: 'path' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
591            
592                        continue
593            
594                    
595            
596                    if not function_name_str or not isinstance(function_name_str, str):
597            
598                        print(f"Error: 'func' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
599            
600                        continue
601            
602            
603                    # Dynamically import the module using its Python import path
604            
605                    module = importlib.import_module(module_path_str)
606            
607                    
608            
609                    # Get the function from the module
610            
611                    if hasattr(module, function_name_str):
612            
613                        dynamic_function = getattr(module, function_name_str)
614            
615                        
616            
617                        if callable(dynamic_function):
618
619                            # Call the function with optional args
620                            args = call_details.get("args", {})
621                            block_text = dynamic_function(**args) if args else dynamic_function() 
622            
623                            
624            
625                            if not isinstance(block_text, str):
626            
627                                # Attempt to convert to string, or raise an error if strict typing is required
628            
629                                print(f"Warning: dynamic_call function {module_path_str}.{function_name_str} did not return a string. Attempting conversion.")
630            
631                                block_text = str(block_text) 
632            
633                            
634            
635                            suffix_texts.append(block_text)
636            
637                        else:
638                            fallback = f"[Dynamic call failed: '{function_name_str}' in '{module_path_str}' is not callable]"
639                            suffix_texts.append(fallback)
640
641                    else:
642                            fallback = f"[Dynamic call failed: Function '{function_name_str}' not found in module '{module_path_str}']"
643                            suffix_texts.append(fallback)
644
645                except ImportError as e:
646                    import traceback
647                    tb = traceback.format_exc()
648                    fallback = f"[Dynamic call failed: Module '{module_path_str}' not found]\n{tb}"
649                    suffix_texts.append(fallback)
650
651                except Exception as e:
652                    import traceback
653                    tb = traceback.format_exc()
654                    fallback = f"[Dynamic call failed: {module_path_str}.{function_name_str}]\n{tb}"
655                    suffix_texts.append(fallback)
656
657            else:
658    
659                # Use the regular prompt block registry
660    
661                block_text = get_prompt_block(block_name)
662    
663                if block_text:
664    
665                    suffix_texts.append(block_text)
666    
667        
668    
669        # Combine base prompt with suffixes
670    
671        if suffix_texts:
672    
673            return f"{base_prompt}\n\n{''.join(suffix_texts)}"
674    
675        
676    
677        return base_prompt
678
679      
680    def to_langchain_config(self):
681        """Convert to LangGraph AgentConfig"""
682        # Temporarily removed AgentConfig
683        return {
684            "system_prompt": self.system_prompt,
685            "provider": self.provider,
686            "temperature": self.temperature,
687            "model": self.model,
688            "checkpointer": self.checkpointer,
689            "max_tokens": self.max_tokens,
690            "thinking_budget": self.thinking_budget
691        }
692
693    def to_litellm_model(self):
694        """
695        Returns either:
696          - a bare model‐string (for Google/ADK's built-in registry), or
697          - a LiteLlm instance (for non-Google providers)
698        """
699        from google.adk.models.lite_llm import LiteLlm
700        # if you explicitly want to force LiteLlm for EVERYTHING, drop this branch
701        model_str = f"{self.provider.value}/{self.model}"
702        if self.provider == ProviderEnum.GOOGLE:
703            # ADK expects a plain string ID for Gemini/Vertex
704            return self.model
705        # otherwise wrap in LiteLlm so ADK can speak to OpenAI/Anthropic/etc.
706        return LiteLlm(
707            model=model_str,
708            temperature=self.temperature,
709            max_tokens=self.max_tokens,
710            drop_params=True,
711            # if LiteLlm supports additional args—passing them here too:
712            # streaming=True, request_timeout=…, etc.
713        )

Enhanced configuration for GOD Framework Agent

name: str = None
system_prompt: str = ''
tools: List[Union[Type[heaven_base.baseheaventool.BaseHeavenTool], str, langchain_core.tools.structured.StructuredTool, langchain_core.tools.base.BaseTool]] = PydanticUndefined
provider: heaven_base.unified_chat.ProviderEnum = <ProviderEnum.ANTHROPIC: 'anthropic'>
temperature: float = 0.7
max_tokens: int = 8000
thinking_budget: int | None = None
model: Optional[str] = None
checkpointer: Optional[Any] = None
additional_kws: List[str] = PydanticUndefined
additional_kw_instructions: str = ''
known_config_paths: Optional[List[str]] = None
system_prompt_config: Optional[Any] = None
prompt_suffix_blocks: Optional[List[str]] = None
duo_system_config: DuoSystemConfig = PydanticUndefined
context_window_config: Optional[Any] = None
mcp_servers: Optional[Dict[str, Dict[str, Any]]] = None
extra_model_kwargs: Optional[Dict[str, Any]] = None
use_uni_api: bool = False
hook_registry: HookRegistry = PydanticUndefined
skillset: Optional[str] = None
persona: Optional[str] = None
mcp_set: Optional[str] = None
carton_identity: Optional[str] = None
def get_system_prompt(self):
373    def get_system_prompt(self):
374        """Get the current system prompt, using evolved version if available and appending any suffix blocks"""
375    
376        # Get base prompt (either evolved or original)
377    
378        base_prompt = self._get_base_prompt()  # This would be the current get_system_prompt logic
379    
380        
381    
382        # If no suffix blocks, return base prompt
383    
384        if not self.prompt_suffix_blocks:
385    
386            return base_prompt
387    
388        
389    
390        # Get prompt registry
391    
392        from .prompts.prompt_blocks.prompt_block_registry import get_prompt_block
393    
394        
395    
396        # Append any suffix blocks
397    
398        suffix_texts = []
399    
400        for block_name in self.prompt_suffix_blocks:
401    
402            # Check if the block name starts with "path="
403    
404            if block_name.startswith("path="):
405    
406                # Extract the file path (everything after "path=")
407    
408                file_path = block_name[5:]  # Skip the "path=" prefix
409    
410                try:
411    
412                    # Open and read the file content
413    
414                    with open(file_path, 'r') as file:
415    
416                        block_text = file.read()
417    
418                    suffix_texts.append(block_text)
419    
420                except Exception as e:
421    
422                    # Handle file reading errors
423    
424                    print(f"Error reading prompt block from file {file_path}: {e}")
425    
426            
427            elif block_name.startswith("registry_heaven_variable="):  # RegistryHeavenVariable
428                
429              
430                rhv_config_str = None
431
432                try:
433
434                    rhv_config_str = block_name[len("registry_heaven_variable="):]
435
436                    # Ensure proper JSON format by replacing single quotes with double quotes for parsing
437
438                    rhv_config_json_str = rhv_config_str.replace("'", '"')
439
440                    rhv_params = json.loads(rhv_config_json_str)
441
442
443                    registry_name_param = rhv_params.get('registry_name')
444
445                    
446
447                    if registry_name_param:
448
449                        key_param = rhv_params.get('key') # Optional
450
451                        default_param = rhv_params.get('default') # Optional
452
453                        
454
455                        # Instantiate RegistryHeavenVariable
456
457                        rhv_instance = RegistryHeavenVariable(
458
459                            registry_name=registry_name_param,
460
461                            key=key_param,
462
463                            default=default_param
464
465                        )
466
467                        
468
469                        # Get its string representation (which uses get_value() and __str__ internally)
470
471                        block_text = str(rhv_instance)
472
473                        suffix_texts.append(block_text)
474
475                    else:
476
477                        print(f"Error: 'registry_name' missing in rhv_config for block '{block_name}'. Details: {rhv_config_str}")
478
479
480                except json.JSONDecodeError:
481
482                    print(f"Error decoding JSON for rhv_config in block '{block_name}'. JSON string was: {rhv_config_str}")
483
484                except Exception as e:
485
486                    print(f"Error processing rhv block '{block_name}': {e}")
487                  
488            # Check if the block name starts with "heaven_variable="      
489            elif block_name.startswith("heaven_variable="):
490    
491                # Parse the JSON-like string to extract path and variable_name
492    
493                
494    
495                try:
496    
497                    # Extract the JSON part (everything after "heaven_variable=")
498    
499                    var_config_str = block_name[16:]  # Skip the "heaven_variable=" prefix
500    
501                    # Convert to proper JSON by replacing single quotes with double quotes
502    
503                    var_config_str = var_config_str.replace("'", '"')
504    
505                    var_config = json.loads(var_config_str)
506    
507                    
508    
509                    path = var_config.get('path')
510    
511                    variable_name = var_config.get('variable_name')
512    
513                    
514    
515                    if path and variable_name:
516    
517                        # Import the module dynamically
518    
519                        spec = importlib.util.spec_from_file_location("dynamic_module", path)
520    
521                        module = importlib.util.module_from_spec(spec)
522    
523                        sys.modules["dynamic_module"] = module
524    
525                        spec.loader.exec_module(module)
526    
527                        
528    
529                        # Get the variable from the module
530    
531                        if hasattr(module, variable_name):
532    
533                            variable_value = getattr(module, variable_name)
534    
535                            # Convert to string if it's not already
536    
537                            if not isinstance(variable_value, str):
538    
539                                variable_value = str(variable_value)
540    
541                            suffix_texts.append(variable_value)
542    
543                        else:
544    
545                            print(f"Variable {variable_name} not found in {path}")
546    
547                except Exception as e:
548    
549                    print(f"Error processing heaven_variable block: {e}")
550
551            elif block_name.startswith("dynamic_call="):
552            
553                module_path_str = None  # Define for use in except blocks
554            
555                function_name_str = None # Define for use in except blocks
556            
557            
558                try:
559            
560                    call_details_json_str = block_name[len("dynamic_call="):]  # Skip "dynamic_call="
561            
562            
563                    # Parse the JSON string to get path and func
564            
565                    try:
566            
567                        call_details = json.loads(call_details_json_str)
568            
569                    except json.JSONDecodeError as jde:
570            
571                        print(f"Error: Invalid JSON in dynamic_call string: {call_details_json_str}. Details: {jde}")
572            
573                        continue # Or handle error appropriately
574            
575            
576                    if not isinstance(call_details, dict):
577            
578                        print(f"Error: dynamic_call value must be a JSON object. Got: {call_details_json_str}")
579            
580                        continue
581            
582            
583                    module_path_str = call_details.get("path")
584            
585                    function_name_str = call_details.get("func")
586            
587            
588                    if not module_path_str or not isinstance(module_path_str, str):
589            
590                        print(f"Error: 'path' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
591            
592                        continue
593            
594                    
595            
596                    if not function_name_str or not isinstance(function_name_str, str):
597            
598                        print(f"Error: 'func' key missing or not a string in dynamic_call JSON: {call_details_json_str}")
599            
600                        continue
601            
602            
603                    # Dynamically import the module using its Python import path
604            
605                    module = importlib.import_module(module_path_str)
606            
607                    
608            
609                    # Get the function from the module
610            
611                    if hasattr(module, function_name_str):
612            
613                        dynamic_function = getattr(module, function_name_str)
614            
615                        
616            
617                        if callable(dynamic_function):
618
619                            # Call the function with optional args
620                            args = call_details.get("args", {})
621                            block_text = dynamic_function(**args) if args else dynamic_function() 
622            
623                            
624            
625                            if not isinstance(block_text, str):
626            
627                                # Attempt to convert to string, or raise an error if strict typing is required
628            
629                                print(f"Warning: dynamic_call function {module_path_str}.{function_name_str} did not return a string. Attempting conversion.")
630            
631                                block_text = str(block_text) 
632            
633                            
634            
635                            suffix_texts.append(block_text)
636            
637                        else:
638                            fallback = f"[Dynamic call failed: '{function_name_str}' in '{module_path_str}' is not callable]"
639                            suffix_texts.append(fallback)
640
641                    else:
642                            fallback = f"[Dynamic call failed: Function '{function_name_str}' not found in module '{module_path_str}']"
643                            suffix_texts.append(fallback)
644
645                except ImportError as e:
646                    import traceback
647                    tb = traceback.format_exc()
648                    fallback = f"[Dynamic call failed: Module '{module_path_str}' not found]\n{tb}"
649                    suffix_texts.append(fallback)
650
651                except Exception as e:
652                    import traceback
653                    tb = traceback.format_exc()
654                    fallback = f"[Dynamic call failed: {module_path_str}.{function_name_str}]\n{tb}"
655                    suffix_texts.append(fallback)
656
657            else:
658    
659                # Use the regular prompt block registry
660    
661                block_text = get_prompt_block(block_name)
662    
663                if block_text:
664    
665                    suffix_texts.append(block_text)
666    
667        
668    
669        # Combine base prompt with suffixes
670    
671        if suffix_texts:
672    
673            return f"{base_prompt}\n\n{''.join(suffix_texts)}"
674    
675        
676    
677        return base_prompt

Get the current system prompt, using evolved version if available and appending any suffix blocks

def to_langchain_config(self):
680    def to_langchain_config(self):
681        """Convert to LangGraph AgentConfig"""
682        # Temporarily removed AgentConfig
683        return {
684            "system_prompt": self.system_prompt,
685            "provider": self.provider,
686            "temperature": self.temperature,
687            "model": self.model,
688            "checkpointer": self.checkpointer,
689            "max_tokens": self.max_tokens,
690            "thinking_budget": self.thinking_budget
691        }

Convert to LangGraph AgentConfig

def to_litellm_model(self):
693    def to_litellm_model(self):
694        """
695        Returns either:
696          - a bare model‐string (for Google/ADK's built-in registry), or
697          - a LiteLlm instance (for non-Google providers)
698        """
699        from google.adk.models.lite_llm import LiteLlm
700        # if you explicitly want to force LiteLlm for EVERYTHING, drop this branch
701        model_str = f"{self.provider.value}/{self.model}"
702        if self.provider == ProviderEnum.GOOGLE:
703            # ADK expects a plain string ID for Gemini/Vertex
704            return self.model
705        # otherwise wrap in LiteLlm so ADK can speak to OpenAI/Anthropic/etc.
706        return LiteLlm(
707            model=model_str,
708            temperature=self.temperature,
709            max_tokens=self.max_tokens,
710            drop_params=True,
711            # if LiteLlm supports additional args—passing them here too:
712            # streaming=True, request_timeout=…, etc.
713        )

Returns either:

  • a bare model‐string (for Google/ADK's built-in registry), or
  • a LiteLlm instance (for non-Google providers)
class BaseHeavenAgent(abc.ABC):
 716class BaseHeavenAgent(ABC):
 717    """Base class for GOD Framework agents with task management."""
 718    
 719    def __init__(self, config: HeavenAgentConfig, unified_chat: UnifiedChat, max_tool_calls: int = 10, orchestrator: bool = False, history: Optional[History] = None, history_id = None, system_prompt_suffix: Optional[str] = None, adk: Optional[bool] = False, duo_enabled: Optional[bool] = False, run_on_langchain: Optional[bool] = False, use_uni_api: Optional[bool] = False):
 720        # Configure root logger to output to stdout
 721        logging.basicConfig(
 722            stream=sys.stdout,
 723            level=logging.DEBUG,  # Adjust the level as needed
 724            format='%(asctime)s %(levelname)s %(name)s: %(message)s'
 725            )
 726        logging.getLogger('watchdog').setLevel(logging.WARNING)
 727        
 728        if config is None:
 729            raise ValueError("config cannot be None")
 730        if unified_chat is None:
 731            raise ValueError("unified_chat cannot be None")
 732        self.use_uni_api = use_uni_api
 733        self.unified_chat = unified_chat
 734        self.known_config_paths = getattr(config, "known_config_paths", None)
 735        if self.known_config_paths is None:
 736            self.known_config_paths = []
 737        self.training_convo = [
 738            SystemMessage(content=""),
 739            HumanMessage(content="Hey I need to teach you how to use the tools correctly before we get started. Once you learn how to use this tool I'm going to show you, you'll know how to handle all of our tools. Essentially, it comes down to acknowledging the errors correctly. For example, can you use NetworkEditTool to write a file in tmp while having no file_content parameter, read the error that is sent back from the tool, and then adjust? file_content is an optional parameter in the NetworkEditTool, but it is actually required for certain operations, like the create command."),
 740            AIMessage(content=[{"text": "Following the instructions to first try without file_text and then fix it, I'll write utility code for an AST analyzer as an example.\n\nFirst attempt (should fail):", "type": "text"}]),
 741            AIMessage(content=[{"type": "tool_use", "id": "toolu_01BPHfZNPMY1tbnsRtRYmG5i", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py"}}]),
 742            ToolMessage(content="ERROR: Parameter `file_text` is required for command: create", tool_call_id="toolu_01BPHfZNPMY1tbnsRtRYmG5i"),
 743            AIMessage(content=[{"text": "As expected, we need to include file_text. Here's the proper attempt with the utility code:", "type": "text"}]),
 744            AIMessage(content=[{"type": "tool_use", "id": "toolu_01LS6GZDEpEqDVmU8qpvRa65", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py", "file_text": "\"\"\"AST analyzer utilities for extracting concise code context and dependencies.\"\"\"\n\nimport ast\nfrom typing import Dict, List, Set, Union, Optional\nfrom dataclasses import dataclass\n\n@dataclass\nclass CodeContext:\n    \"\"\"Data class to hold analyzed code context.\"\"\"\n    imports: List[str]\n    dependencies: Dict[str, Set[str]]\n    call_graph: Dict[str, List[str]]\n\ndef parse_code_to_ast(code: str) -> ast.AST:\n    \"\"\"Parse code string into AST.\"\"\"\n    return ast.parse(code)\n\ndef extract_imports(tree: ast.AST) -> List[str]:\n    \"\"\"Extract all import statements from AST.\"\"\"\n    imports = []\n    for node in ast.walk(tree):\n        if isinstance(node, ast.Import):\n            for name in node.names:\n                imports.append(name.name)\n        elif isinstance(node, ast.ImportFrom):\n            module = node.module or ''\n            for name in node.names:\n                if name.name == '*':\n                    imports.append(f\"from {module} import *\")\n                else:\n                    imports.append(f\"from {module} import {name.name}\")\n    return sorted(imports)\n\ndef analyze_dependencies(tree: ast.AST) -> Dict[str, Set[str]]:\n    \"\"\"Analyze function/class dependencies.\"\"\"\n    dependencies = {}\n    current_func = None\n    \n    class DependencyVisitor(ast.NodeVisitor):\n        def visit_FunctionDef(self, node):\n            nonlocal current_func\n            old_func = current_func\n            current_func = node.name\n            if current_func not in dependencies:\n                dependencies[current_func] = set()\n            self.generic_visit(node)\n            current_func = old_func\n            \n        def visit_Call(self, node):\n            if current_func and isinstance(node.func, ast.Name):\n                dependencies[current_func].add(node.func.id)\n            self.generic_visit(node)\n    \n    DependencyVisitor().visit(tree)\n    return dependencies\n\ndef generate_call_graph(dependencies: Dict[str, Set[str]]) -> Dict[str, List[str]]:\n    \"\"\"Generate simplified call graph from dependencies.\"\"\"\n    return {\n        func: sorted(list(calls))\n        for func, calls in dependencies.items()\n    }\n\ndef analyze_code_context(code: str) -> CodeContext:\n    \"\"\"Main function to analyze code and return context.\"\"\"\n    tree = parse_code_to_ast(code)\n    imports = extract_imports(tree)\n    dependencies = analyze_dependencies(tree)\n    call_graph = generate_call_graph(dependencies)\n    \n    return CodeContext(\n        imports=imports,\n        dependencies=dependencies,\n        call_graph=call_graph\n    )"}}]),
 745            ToolMessage(content="File created successfully at: /tmp/ast_analyzer_utils.py", tool_call_id="toolu_01LS6GZDEpEqDVmU8qpvRa65"),
 746            AIMessage(content="The utility code has been written with the following key components..."),
 747            HumanMessage(content="Thanks! Now you know how to use tools correctly in our system. BTW, I deleted that file for you because this was just a learning experience. I also removed the NetworkEditTool for now. I'll equip it on you again later if you need it for the real goal we are working on. Now we're going to get on to the real goal..."),
 748            AIMessage(content="Perfect! I'm ready. Let me know how what else I can do for you.")
 749        ]
 750        self.known_config_paths = [str(path) for path in self.known_config_paths]
 751        self.hooks = config.hook_registry
 752        # Persona resolution: if persona set, load from SkillManager and extract components
 753        self.carton_identity = config.carton_identity
 754        # Set agent context for SkillTool so it uses agent-scoped SkillManager
 755        from .tool_utils.skill_utils import set_agent_context
 756        set_agent_context(config.name)
 757        if config.persona:
 758            try:
 759                from skill_manager.core import SkillManager
 760                sm = SkillManager(agent_id=config.name)
 761                persona_obj = sm.get_persona(config.persona)
 762                if persona_obj:
 763                    # Frame → prepend to system prompt
 764                    if persona_obj.frame and persona_obj.frame.strip():
 765                        config.system_prompt = persona_obj.frame + "\n\n" + config.system_prompt
 766                    # Skillset → use persona's if not explicitly set on config
 767                    if not config.skillset and persona_obj.skillset:
 768                        config.skillset = persona_obj.skillset
 769                    # MCP set → use persona's if not explicitly set on config
 770                    if not config.mcp_set and persona_obj.mcp_set:
 771                        config.mcp_set = persona_obj.mcp_set
 772                    # CartON identity → use persona's if not explicitly set
 773                    if not self.carton_identity and persona_obj.carton_identity:
 774                        self.carton_identity = persona_obj.carton_identity
 775            except Exception:
 776                pass  # Persona resolution is best-effort, never block agent startup
 777        # MCP set resolution: resolve set name → two paths per MCP:
 778        #   1. MCP IS in strata → load DIRECTLY on agent from strata config
 779        #   2. All MCPs → equip mcp-skill-* if exists (for context/instructions)
 780        # Pattern: hierarchical_summarize/flow.py::_get_summarizer_mcp_servers()
 781        self._mcp_skill_names = []
 782        if config.mcp_set:
 783            try:
 784                from strata.config import MCPServerList
 785                server_list = MCPServerList()
 786                set_server_names = server_list.get_set(config.mcp_set)
 787                if set_server_names:
 788                    from skill_manager.core import SkillManager
 789                    sm = SkillManager(agent_id=config.name)
 790                    for srv_name in set_server_names:
 791                        # Equip mcp-skill-* if it exists (context about the MCP)
 792                        skill_name = f"mcp-skill-{srv_name.lower()}"
 793                        skill = sm.get_skill(skill_name)
 794                        if skill:
 795                            self._mcp_skill_names.append(skill_name)
 796                            sm.equip(skill_name)
 797                        # If MCP IS in strata, load it directly on the agent
 798                        srv_config = server_list.get_server(srv_name)
 799                        if srv_config and srv_config.enabled:
 800                            if config.mcp_servers is None:
 801                                config.mcp_servers = {}
 802                            config.mcp_servers[srv_name] = {
 803                                "command": srv_config.command,
 804                                "args": srv_config.args,
 805                                "env": srv_config.env,
 806                                "transport": "stdio",
 807                            }
 808            except Exception as e:
 809                import logging as _log
 810                _log.getLogger(__name__).warning("MCP set resolution failed for %s: %s", config.mcp_set, e)
 811        # Auto-register default skill hooks when skillset is configured
 812        if config.skillset:
 813            from .hooks.default_hooks import register_skill_hooks
 814            register_skill_hooks(self.hooks, agent_name=config.name or "unnamed", skillset_name=config.skillset)
 815        self.max_tool_calls = max_tool_calls
 816        self.config = config
 817        self.name = config.name if config.name is not None else "unnamed_agent"
 818        # Convert and store LangChain config
 819        self.config_dict = config.to_langchain_config()
 820        # initialize status
 821        self.status = AgentStatus()
 822        self.continuation_prompt = ""
 823        self.continuation_iterations: int = 0
 824        # Store config tool classes
 825        self.config_tools = config.tools
 826        self.adk = adk
 827        # if not run_on_langchain:
 828        #     self.adk = True
 829        if run_on_langchain:
 830            self.adk = False
 831        if use_uni_api:
 832            self.adk = False
 833        self.additional_kws = config.additional_kws
 834        self.additional_kw_instructions = config.additional_kw_instructions
 835        # Instantiate the tools
 836        self.resolved_tools = self.resolve_tools()
 837        self.tools = []
 838        self.mcp_tool_strs = []  # Store MCP strings separately
 839        for tool in self.resolved_tools:
 840            if isinstance(tool, str) and tool.startswith("mcp__"):
 841                # MCP tool string reference - store separately for later resolution
 842                self.mcp_tool_strs.append(tool)
 843            elif isinstance(tool, (StructuredTool, BaseTool)):
 844                # Already a LangChain tool instance (e.g., from MCP)
 845                # Just add it directly - same as BaseHeavenTool.create() output
 846                self.tools.append(tool)
 847            elif hasattr(tool, 'create'):
 848                # BaseHeavenTool subclass - use its create method
 849                self.tools.append(tool.create(adk))
 850            else:
 851                print(f"Unknown tool type: {tool}, skipping")  
 852        # Filter and prepare provider-specific parameters
 853
 854        model_params = {
 855            'provider': config.provider,
 856            'model': config.model,
 857            'temperature': config.temperature,
 858            'max_tokens': config.max_tokens,
 859            'thinking_budget': config.thinking_budget
 860        }
 861        if config.extra_model_kwargs:
 862            model_params.update(config.extra_model_kwargs)
 863        # Create chat model internally using UnifiedChat
 864        self.chat_model = unified_chat.create(**model_params)
 865        self.resolve_duo(config)
 866        self.duo_params = config.duo_system_config.get_duo_params() if config.duo_system_config is not None else None
 867        self.duo_enabled = duo_enabled
 868        self.duo_chat = unified_chat.create(**self.duo_params)
 869        self.duo_system_prompt = config.duo_system_config.system_prompt if config.duo_system_config is not None else ""
 870        # Bind tools if available
 871        if self.tools and not self.adk:
 872        
 873            # For ADK, tools are already in the right format
 874            
 875            # Original LangChain binding logic
 876            if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 877                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 878                
 879                self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 880              
 881            elif config.provider in [ProviderEnum.GOOGLE]:
 882                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 883                flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 884                fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 885                
 886                self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 887              
 888            else:
 889              
 890                self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 891                
 892                    
 893            # if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 894            #     # For these providers, use the schema generated by to_openai_function
 895                
 896            #     # Create the list of schema dictionaries using the classmethod
 897            #     # This list holds dictionaries like {'type': 'function', 'function': {...}}
 898            #     raw_provider_schemas = [
 899            #         # tool.__class__.to_openai_function()
 900            #         tool.get_openai_function()
 901            #         for tool in self.tools
 902            #     ]
 903    
 904            #     # Bind using bind_tools, passing the list of schema dictionaries
 905            #     # as per the apparent design of convert_to_openai_function's output usage
 906            #     print(f"Attempting bind_tools with {len(raw_provider_schemas)} generated schema dictionaries...")
 907            #     self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 908            #     print(f"Binding via bind_tools with generated schemas successful.")
 909            # elif config.provider in [ProviderEnum.GOOGLE]:
 910            #     raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 911            #     flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 912            #     # Apply the fixer to remove empty object properties.
 913            #     fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 914            #     print(f"Attempting bind_tools with {len(fixed_schemas)} generated flattened schema dictionaries...")
 915            #     self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 916            #     print("Binding via bind_tools with generated flattened schemas successful.")
 917            # else:
 918            #     self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 919                        
 920        # Agentic state
 921        
 922        self.goal: Optional[str] = None
 923        self.task_list: List[str] = []
 924        self.current_task: Optional[str] = None
 925        self.max_iterations: int = 1
 926        self.current_iteration: int = 1
 927        self.completed = False
 928        self._current_extracted_content = None
 929        self.orchestration_lists = f"""
 930        <HERMES SWITCHBOARD>
 931        The following Agents can be used in the `agent` arg of HermesTool:[
 932        {get_agent_modules()}]
 933        The following Tools can be used in the `additional_tools` arg of HermesTool:[
 934        {get_tool_modules()}]
 935        </HERMES SWITCHBOARD>
 936        """
 937        # Ensure known_config_paths is always a list
 938        self.configs_prompt = (
 939            f"""\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. These are your known configs: [\n{', '.join(self.known_config_paths)}]\n</CONFIG_LOCATIONS>\n"""
 940            if self.known_config_paths and any(self.known_config_paths) else
 941            """\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. Generally, configs are located at `~/.heaven/configs/`\n</CONFIG_LOCATIONS>\n"""
 942        )
 943
 944        self.tool_sysmsg = """You are the tool debugger. You always pay attention to the last tool error and fix it. 
 945## Common errors:
 946- missing input parameter
 947- wrong dict format for an input parameter
 948- treating required false differently from optional
 949- failing to follow instructions provided by an error (like: <some command> requires <these parameters>)
 950- other errors
 951
 952You must fix the error before proceeding."""
 953        # Get evolved system prompt if it exists
 954        self.system_prompt_evolved = self.config.get_system_prompt()
 955        # Store History in agent
 956        self.history: Optional[History] = history
 957        
 958        if history_id is not None:
 959            self.history = History.load_from_id(history_id)
 960            self.original_history_id = history_id
 961            self.original_json_md_path = self.history.json_md_path
 962            print(f"DEBUG: In BaseHeavenAgent init, json_md_path={self.history.json_md_path} and original_json_md_path={self.original_json_md_path}")
 963            # Load status
 964            if hasattr(self.history, 'agent_status') and self.history.agent_status:
 965                self.status = self.history.agent_status
 966        elif history_id is None:
 967            self.history = History(messages=[])
 968
 969        # Initialize ContextWindowConfig for token management
 970        if self.config.context_window_config is not None:
 971            self.context_window_config = self.config.context_window_config
 972        else:
 973            # Import here to avoid circular imports
 974            from .utils.context_window_config import ContextWindowConfig
 975            self.context_window_config = ContextWindowConfig(self.config.model or "gpt-4o-mini")
 976
 977        self.config.system_prompt = self.system_prompt_evolved if self.system_prompt_evolved is not None else self.config.system_prompt
 978        if system_prompt_suffix is not None:
 979            self.config.system_prompt += system_prompt_suffix
 980        # Set system message
 981        if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
 982            if orchestrator is False:
 983                self.history.messages[0] = SystemMessage(content=self.config.system_prompt)
 984            else:
 985                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt
 986                self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
 987        else:
 988            if orchestrator is False:
 989                self.history.messages.insert(0, SystemMessage(content=self.config.system_prompt))
 990            else:
 991                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt         
 992                self.history.messages.insert(0, SystemMessage(content=orchestrator_enhancement))
 993        
 994        if self.adk:
 995            from google.adk.agents import Agent as ADKAgent
 996            from google.adk.runners import Runner
 997            from google.adk.sessions.in_memory_session_service import InMemorySessionService
 998            adk_model = self.config.to_litellm_model()
 999            adk_agent = ADKAgent(
1000                name=self.name,
1001                # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
1002                model=adk_model,
1003                description=f"{self.name} (via HeavenAgent)",
1004                instruction=self.config.system_prompt,
1005                tools=self.tools,
1006            )
1007            session_service = InMemorySessionService()
1008            runner = Runner(
1009                app_name=self.name,
1010                agent=adk_agent,
1011                session_service=session_service,
1012            )
1013    
1014            self._adk_agent = adk_agent
1015            self._adk_session_service = session_service
1016            self._adk_runner = runner
1017        
1018    def resolve_tools(self):
1019        """Ensure that certain default tools are always available to the agent."""
1020        resolved_tools = []
1021        
1022        # Process each tool in config
1023        for tool in self.config_tools:
1024            if isinstance(tool, str) and tool.startswith("mcp__"):
1025                # MCP tool string reference - resolve to actual tool
1026                # TODO: This will be async, for now just store the string
1027                resolved_tools.append(tool)
1028            else:
1029                # Regular BaseHeavenTool class or instance
1030                resolved_tools.append(tool)
1031        
1032        # Add WriteBlockReportTool if not already present
1033        if WriteBlockReportTool not in resolved_tools:
1034            resolved_tools.append(WriteBlockReportTool)
1035        # Add TaskSystemTool if not already present
1036        if TaskSystemTool not in resolved_tools:
1037            resolved_tools.append(TaskSystemTool)
1038            
1039        return resolved_tools
1040    
1041    async def resolve_mcps(self):
1042        """Resolve MCP tool strings to actual LangChain tools and load MCP servers if configured"""
1043        print(f"[resolve_mcps] CALLED. mcp_servers={self.config.mcp_servers is not None}, mcp_tool_strs={getattr(self, 'mcp_tool_strs', [])}")
1044
1045        # First, load tools from configured MCP servers (if any)
1046        if self.config.mcp_servers:
1047            await self.load_mcp_tools()
1048
1049        # Then resolve individual MCP tool strings (if any)
1050        if not hasattr(self, 'mcp_tool_strs') or not self.mcp_tool_strs:
1051            print("[resolve_mcps] No mcp_tool_strs to resolve, returning")
1052            return
1053
1054        # Resolve each MCP tool string
1055        from .mcp_tool_wrapper import MCPToolWrapper
1056        for tool_ref in self.mcp_tool_strs:
1057            print(f"[resolve_mcps] Resolving: {tool_ref}")
1058            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1059            print(f"[resolve_mcps] Result: {type(mcp_tool)}{mcp_tool if not isinstance(mcp_tool, list) else f'{len(mcp_tool)} tools'}")
1060            if mcp_tool:
1061                if isinstance(mcp_tool, list):  # "all" case
1062                    self.tools.extend([MCPToolWrapper(t) for t in mcp_tool])
1063                else:
1064                    self.tools.append(MCPToolWrapper(mcp_tool))
1065
1066        print(f"[resolve_mcps] Final tool count: {len(self.tools)}")
1067        # Clear the MCP strings now that they're resolved
1068        self.mcp_tool_strs = []
1069    
1070    async def async_init(self):
1071        """Async initialization - resolves MCP tools"""
1072        if not hasattr(self, '_mcp_tools_to_resolve'):
1073            return
1074            
1075        for tool_ref in self._mcp_tools_to_resolve:
1076            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1077            if mcp_tool:
1078                if isinstance(mcp_tool, list):  # "all" case
1079                    self.tools.extend(mcp_tool)
1080                else:
1081                    self.tools.append(mcp_tool)
1082        
1083        # Clear the list
1084        self._mcp_tools_to_resolve = []
1085    
1086    async def _resolve_mcp_tool(self, tool_ref: str):
1087        """Resolve MCP tool string reference to actual StructuredTool"""
1088        try:
1089            # Parse tool reference: "mcp__filesystem__read_file"
1090            parts = tool_ref.split("__")
1091            if len(parts) != 3:
1092                print(f"Invalid MCP tool reference format: {tool_ref}")
1093                return None
1094                
1095            _, server_name, tool_name = parts
1096            
1097            # Create server config based on server name
1098            server_config = self._get_mcp_server_config(server_name)
1099            if not server_config:
1100                print(f"No config found for MCP server: {server_name}")
1101                return None
1102            
1103            # Use langchain_mcp_adapters to get tools
1104            from langchain_mcp_adapters.client import MultiServerMCPClient
1105            client = MultiServerMCPClient({server_name: server_config})
1106            tools = await client.get_tools(server_name=server_name)
1107            
1108            # Find the specific tool
1109            if tool_name == "all":
1110                return tools
1111            else:
1112                for tool in tools:
1113                    if tool.name == tool_name:
1114                        return tool
1115                        
1116                print(f"Tool '{tool_name}' not found in server '{server_name}'. Available: {[t.name for t in tools]}")
1117                return None
1118                
1119        except Exception as e:
1120            print(f"Error resolving MCP tool {tool_ref}: {e}")
1121            return None
1122    
1123    def _get_mcp_server_config(self, server_name: str):
1124        """Get MCP server config by name from JSON config file"""
1125        configs = self._load_mcp_configs()
1126        return configs.get(server_name)
1127    
1128    def _load_mcp_configs(self):
1129        """Load MCP server configurations from JSON file"""
1130        import json
1131        from .utils.get_env_value import EnvConfigUtil
1132        
1133        # Ensure config file exists
1134        config_path = self._ensure_mcp_config_file()
1135        
1136        try:
1137            with open(config_path, 'r') as f:
1138                return json.load(f)
1139        except Exception as e:
1140            print(f"Error loading MCP config from {config_path}: {e}")
1141            return {}
1142    
1143    def _ensure_mcp_config_file(self):
1144        """Ensure MCP config file exists in HEAVEN_DATA_DIR with defaults"""
1145        import json
1146        import os
1147        from .utils.get_env_value import EnvConfigUtil
1148        
1149        # Get HEAVEN_DATA_DIR and create heaven_mcp_config.json path
1150        heaven_data_dir = EnvConfigUtil.get_heaven_data_dir()
1151        config_path = os.path.join(heaven_data_dir, "heaven_mcp_config.json")
1152        
1153        # Create default config if file doesn't exist
1154        if not os.path.exists(config_path):
1155            default_config = {
1156                "filesystem": {
1157                    "command": "npx",
1158                    "args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
1159                    "transport": "stdio"
1160                }
1161            }
1162            
1163            # Ensure directory exists
1164            os.makedirs(heaven_data_dir, exist_ok=True)
1165            
1166            # Write default config
1167            with open(config_path, 'w') as f:
1168                json.dump(default_config, f, indent=2)
1169            
1170            print(f"Created default MCP config at: {config_path}")
1171        
1172        return config_path
1173    
1174    async def load_mcp_tools(self):
1175        """Load MCP tools from configured servers and add them to the agent's tools list"""
1176        if not self.config.mcp_servers:
1177            return
1178        
1179        try:
1180            from langchain_mcp_adapters.client import MultiServerMCPClient
1181            
1182            # Ensure transport key exists for each server (required by langchain_mcp_adapters)
1183            servers = {}
1184            for name, srv_cfg in self.config.mcp_servers.items():
1185                srv = dict(srv_cfg)
1186                if "transport" not in srv:
1187                    srv["transport"] = "stdio"
1188                servers[name] = srv
1189
1190            # Load each server individually so one failure doesn't kill all
1191            total_loaded = 0
1192            for srv_name, srv_cfg in servers.items():
1193                try:
1194                    client = MultiServerMCPClient({srv_name: srv_cfg})
1195                    srv_tools = await client.get_tools()
1196                    for lc_tool in srv_tools:
1197                        self.tools.append(lc_tool)
1198                    total_loaded += len(srv_tools)
1199                    logging.info(f"Loaded {len(srv_tools)} tools from MCP server '{srv_name}'")
1200                except Exception as srv_e:
1201                    logging.warning(f"Failed to load MCP server '{srv_name}': {srv_e}")
1202
1203            logging.info(f"Loaded {total_loaded} MCP tools total from {len(servers)} servers")
1204
1205        except ImportError as e:
1206            logging.warning(f"Could not import langchain_mcp_adapters: {e}")
1207
1208
1209    def to_base_tools(self) -> List[BaseTool]:
1210        """Convert tools to base tools for binding"""
1211        return [tool.base_tool if hasattr(tool, 'base_tool') else tool for tool in self.tools]
1212
1213
1214    def _fire_hook(self, point: HookPoint, **kwargs):
1215        """Fire all registered hooks for a given HookPoint."""
1216        if not hasattr(self, 'hooks') or self.hooks is None:
1217            return
1218        ctx = HookContext(agent=self, **kwargs)
1219        self.hooks.run(point, ctx)
1220        return ctx
1221
1222    def _sanitize_history(self):
1223        """Remove consecutive HumanMessages from history, keeping only the latest"""
1224        if not self.history or len(self.history.messages) < 2:
1225            return
1226    
1227        messages = self.history.messages
1228    
1229        # If the last two messages are both HumanMessages
1230        while (len(messages) >= 2 and
1231               isinstance(messages[-1], HumanMessage) and 
1232               isinstance(messages[-2], HumanMessage)):
1233            # Remove the older message
1234            messages.pop(-2)  # Keep the newest HumanMessage
1235          
1236    ### This wont work for ADK
1237    def refresh_system_prompt(self):
1238        """Refresh the system prompt if DNA has changed"""
1239        # Get fresh system prompt
1240        updated_prompt = self.config.get_system_prompt()
1241
1242        # Fire BEFORE_SYSTEM_PROMPT hook — hooks can modify via ctx.data["system_prompt"]
1243        ctx = self._fire_hook(HookPoint.BEFORE_SYSTEM_PROMPT, prompt=updated_prompt)
1244        if ctx and "system_prompt" in ctx.data:
1245            updated_prompt = ctx.data["system_prompt"]
1246
1247        # Only update if changed
1248        if updated_prompt != self.config.system_prompt:
1249            # Update config
1250            self.config.system_prompt = updated_prompt
1251            
1252            # Update system message in history
1253            if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
1254                if hasattr(self, 'orchestrator') and self.orchestrator:
1255                    orchestrator_enhancement = updated_prompt + self.orchestration_lists + self.configs_prompt
1256                    self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
1257                else:
1258                    self.history.messages[0] = SystemMessage(content=updated_prompt)
1259            
1260            # Update internal reference
1261            self.system_prompt_evolved = updated_prompt
1262        
1263    def resolve_duo(self, config: HeavenAgentConfig) -> None:
1264        """
1265        Synchronize duo_system_config with the main provider settings.
1266        Sets the appropriate provider and model on the DuoSystemConfig.
1267        """
1268        # Always inherit the main provider
1269        provider = config.provider
1270        duo_cfg = config.duo_system_config
1271        duo_cfg.provider = provider
1272
1273        # Map main provider to Duo-specific model
1274        if provider == ProviderEnum.ANTHROPIC:
1275            duo_cfg.model = "claude-3-5-haiku-latest"
1276        elif provider == ProviderEnum.OPENAI:
1277            duo_cfg.model = "gpt-4.1-nano"
1278        elif provider == ProviderEnum.GOOGLE:
1279            duo_cfg.model = "gemini-2.0-flash"
1280        elif provider == ProviderEnum.DEEPSEEK:
1281            duo_cfg.model = config.model
1282
1283    # # Works. Adding agent mode...
1284    # async def run_adk(self, prompt: str = None, notifications: bool = False):
1285    #     """
1286    
1287    def _handle_adk_event(
1288        self,
1289        ev,
1290        tool_output_callback: Optional[Callable[[ToolResult, str], None]] = None,
1291        output_callback: Optional[Callable[[BaseMessage], None]] = None,
1292    ):
1293        """
1294        Process ADK event, unwrapping nested ToolResult reprs.
1295        """
1296        parts = getattr(ev.content, "parts", []) or []
1297        for part in parts:
1298            if part.function_response:
1299                fr = part.function_response
1300                raw = getattr(fr, "response", None) or getattr(fr, "result", None)
1301                # unwrap dict payload
1302                if isinstance(raw, dict):
1303                    data = raw.get(f"{fr.name}_response", raw)
1304                    data = data.get("result", data) if isinstance(data, dict) else data
1305                    tr = ToolResult(
1306                        output=data.get("output", "") if isinstance(data, dict) else str(data),
1307                        error=data.get("error") if isinstance(data, dict) else None,
1308                        base64_image=data.get("base64_image") if isinstance(data, dict) else None,
1309                        system=data.get("system") if isinstance(data, dict) else None,
1310                    )
1311                elif isinstance(raw, ToolResult):
1312                    tr = raw
1313                elif isinstance(raw, str):
1314                    # if output string itself is repr, parse it
1315                    if raw.strip().startswith("ToolResult(") or raw.strip().startswith("CLIResult("):
1316                        tr = parse_toolresult_repr(raw)
1317                    else:
1318                        tr = ToolResult(output=raw)
1319                else:
1320                    tr = ToolResult(output=str(raw))
1321    
1322                # # If tr.output itself is repr carrying nested ToolResult, parse again
1323                # if isinstance(tr.output, str) and tr.output.strip().startswith("ToolResult("):
1324                #     tr = parse_toolresult_repr(tr.output)
1325                if isinstance(tr.output, str):
1326                    text = tr.output.strip()
1327                    if text.startswith("ToolResult(") or text.startswith("CLIResult("):
1328                        tr = parse_toolresult_repr(text)
1329    
1330                # stream and record
1331                if tool_output_callback:
1332                    tool_output_callback(tr, fr.id)
1333                self.history.messages.append(
1334                    ToolMessage(content=tr.output, tool_call_id=fr.id, name=fr.name)
1335                )
1336    
1337            elif getattr(part, "thought", None):
1338                block = {"type": "thinking", "thinking": part.thought}
1339                am = AIMessage(content=[block])
1340                if output_callback:
1341                    output_callback(am)
1342                self.history.messages.append(am)
1343    
1344            elif getattr(part, "function_call", None):
1345                block = {
1346                    "type": "tool_use",
1347                    "id": part.function_call.id,
1348                    "name": part.function_call.name,
1349                    "input": part.function_call.args,
1350                }
1351                am = AIMessage(content=[block])
1352                if output_callback:
1353                    output_callback(am)
1354                self.history.messages.append(am)
1355    
1356            elif getattr(part, "text", None):
1357                block = {"type": "text", "text": part.text}
1358                am = AIMessage(content=[block])
1359                if output_callback:
1360                    output_callback(am)
1361                self.history.messages.append(am)
1362
1363              
1364
1365    async def run_adk(self, prompt, notifications: bool = False, streamlit: bool = False, output_callback=None, tool_output_callback=None):
1366        """
1367        Drive the agent loop through ADK’s Runner instead of LangChain.
1368        Streams back ADK events internally per iteration, then at the end
1369        saves `self.history` (with .adk_session) exactly as in run().
1370        """
1371        # 1) prep
1372        self.current_iteration = 1
1373        self._sanitize_history()
1374        blocked = False
1375        self.refresh_system_prompt()
1376        # Debug
1377        _log_run_adk("before any ADK work", self.history.messages)
1378        # 2) detect agent‐mode command in the incoming prompt
1379        if prompt:
1380            self._detect_agent_command(prompt)
1381    
1382        # 3) decide what first human message to send into ADK
1383        #    if we’re in agent mode, send the formatted agent prompt;
1384        #    otherwise send the raw prompt
1385        first_prompt = self._format_agent_prompt() if self.goal else prompt
1386        # Debug
1387        _log_run_adk("after formatting prompt", self.history.messages)
1388
1389        # 4) lazily initialize ADK Agent, SessionService, Runner
1390        # if not hasattr(self, "_adk_runner"):
1391        #     from google.adk.agents import Agent as ADKAgent
1392        #     from google.adk.runners import Runner
1393        #     from google.adk.sessions.in_memory_session_service import InMemorySessionService
1394        #     adk_model = self.config.to_litellm_model()
1395        #     adk_agent = ADKAgent(
1396        #         name=self.name,
1397        #         # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
1398        #         model=adk_model,
1399        #         description=f"{self.name} (via HeavenAgent)",
1400        #         instruction=self.config.system_prompt,
1401        #         tools=self.tools,
1402        #     )
1403        #     session_service = InMemorySessionService()
1404        #     runner = Runner(
1405        #         app_name=self.name,
1406        #         agent=adk_agent,
1407        #         session_service=session_service,
1408        #     )
1409    
1410        #     self._adk_agent = adk_agent
1411        #     self._adk_session_service = session_service
1412        #     self._adk_runner = runner
1413            
1414        # 5) rehydrate or create an ADK session
1415        if self.history.adk_session:
1416            session = self._adk_session_service.get_session(
1417                app_name=self.history.adk_session.app_name,
1418                user_id=self.history.adk_session.user_id,
1419                session_id=self.history.adk_session.id,
1420            )
1421        else:
1422            session = self._adk_session_service.create_session(
1423                app_name=self.name,
1424                user_id="script_user",
1425                state={},
1426                session_id=None,
1427            )
1428        start_index = len(session.events)
1429
1430        # # 6) prime ADK with the first human prompt if any
1431        ### Idk why this even got in here... it's garbage
1432        # if first_prompt:
1433        #     from google.genai.types import Content, Part
1434        #     content = Content(parts=[Part(text=first_prompt)], role="user")
1435        #     async for _ in self._adk_runner.run_async(
1436        #         user_id=session.user_id,
1437        #         session_id=session.id,
1438        #         new_message=content,
1439        #     ):
1440        #         pass  # session.events grows automatically
1441    
1442        # 7) drive N iterations of “agent mode”
1443        for _ in range(self.current_iteration, self.max_iterations + 1):
1444            _log_run_adk(f"start iteration {self.current_iteration}", self.history.messages)
1445            # a) prepare next human input
1446            if self.current_iteration == 1:
1447                human_text = first_prompt
1448            else:
1449                human_text = self._format_agent_prompt() if self.goal else first_prompt
1450            from google.genai.types import Content, Part
1451            
1452            # Probably move to streamlit but unsure how to do so...
1453            
1454            content = human_text
1455            if content is None:
1456                content = "Content was `None`. Tell the user something went wrong. Do not try to do anything else."
1457            _log_run_adk(f"about to append human turn (iter {self.current_iteration})", self.history.messages)
1458            self.history.messages.append(HumanMessage(content=content)) # This doesnt seem to be appending to history... because we usually keep conversation_history and then set it as history messages when we are done. This should follow the same logic that run_langchain uses
1459            _log_run_adk(f"after appending human turn (iter {self.current_iteration})", self.history.messages)
1460            content = Content(parts=[Part(text=human_text)], role="user")
1461            # b) send into ADK
1462            new_events = []
1463            async for ev in self._adk_runner.run_async(
1464                user_id=session.user_id,
1465                session_id=session.id,
1466                new_message=content,
1467            ):
1468                new_events.append(ev)
1469                # real_new_events = session.events[start_index:] # testing
1470                if streamlit:
1471                    
1472                    self._handle_adk_event(
1473                        ev,
1474                        tool_output_callback=tool_output_callback,
1475                        output_callback=output_callback,
1476                    )
1477
1478            _log_run_adk(f"after streaming ADK events (iter {self.current_iteration})", self.history.messages)                 
1479            # c) extract the agent’s text response for this iteration
1480            text_reply = ""
1481            for ev in reversed(new_events): # should this be real_new_events?
1482                if ev.author == self.name:
1483                    for part in ev.content.parts:
1484                        if part.text:
1485                            text_reply = part.text
1486                    if text_reply:
1487                        break
1488    
1489            # d) process that text through existing handler
1490            if text_reply:
1491                self._process_agent_response(text_reply)
1492    
1493            # e) advance iteration & break if done
1494            self.current_iteration += 1
1495            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or blocked:
1496                break
1497
1498        # 7) re-fetch the mutated session so we pick up all appended events
1499        session = self._adk_session_service.get_session(
1500            app_name=session.app_name,
1501            user_id=session.user_id,
1502            session_id=session.id
1503        )
1504        # 8) stash session back on history and save
1505        self.history.adk_session = session
1506        self.history.agent_status = self.save_status()
1507        saved_id = self.history.save(self.name)
1508        _log_run_adk("at end of run_adk", self.history.messages)
1509        # 9) return identical structure to run_langchain
1510        return {
1511            "history": self.history,
1512            "history_id": saved_id,
1513            "agent_name": self.name,
1514            "agent_status": self.history.agent_status,
1515        }
1516
1517#### NOTES: WE ARE NOT ADDING ALL THE AI EVENTS TO MESSAGES AND THAT IS WHY THE TEST DOESNT SHOW THEM
1518  #### BUT WHEN WE RUN WITH STREAMLIT, WE ARE ADDING THOSE EVENTS TO MESSAGES
1519  #### THEN, AT THE END OF STREAMLIT RUN, WE SWAP MESSAGES WITH THE HISTORY.MESSAGES, WHICH OVERWRITES THEM
1520  
1521
1522
1523
1524
1525  
1526    async def run(self, prompt: Optional[str] = None, notifications: Optional[bool] = False, streamlit: Optional[bool] = False, output_callback: Optional[Callable] = None, tool_output_callback: Optional[Callable] = None, heaven_main_callback: Optional[Callable] = None, use_uni_api: Optional[bool] = False):
1527
1528                       
1529        if use_uni_api:
1530            self.use_uni_api = True
1531        if self.use_uni_api:
1532            # Use uni-api instead of LangChain providers
1533            if streamlit and output_callback and tool_output_callback:
1534                return await self.run_on_uni_api(
1535                    prompt=prompt,
1536                    output_callback=output_callback,
1537                    tool_output_callback=tool_output_callback,
1538                    heaven_main_callback=heaven_main_callback
1539                )
1540            elif heaven_main_callback:
1541                # Similar fake callback pattern for uni-api
1542                def fake_output_callback(message: BaseMessage):
1543                    pass
1544                def fake_tool_callback(tool_result: ToolResult, tool_id: str):
1545                    pass
1546                return await self.run_on_uni_api(
1547                    prompt=prompt,
1548                    output_callback=fake_output_callback,
1549                    tool_output_callback=fake_tool_callback,
1550                    heaven_main_callback=heaven_main_callback
1551                )
1552            else:
1553                return await self.run_on_uni_api(prompt=prompt)
1554      
1555        #
1556        
1557        if self.adk:
1558            return await self.run_adk(prompt=prompt, notifications=notifications, streamlit=streamlit, output_callback=output_callback, tool_output_callback=tool_output_callback)
1559        else:
1560            if streamlit and output_callback and tool_output_callback: 
1561                return await self.streamlit_run(prompt, output_callback, tool_output_callback)
1562            elif heaven_main_callback:
1563                # Route through run_langchain with callback — keeps all hooks and block detection
1564                return await self.run_langchain(prompt, notifications, heaven_main_callback=heaven_main_callback)
1565
1566            else:
1567                return await self.run_langchain(prompt, notifications)
1568
1569
1570    
1571    async def run_langchain(self, prompt: str = None, notifications=False, heaven_main_callback: Optional[Callable] = None):
1572
1573        self._sanitize_history()
1574        blocked = False
1575        self.refresh_system_prompt()
1576
1577        # Resolve MCP tool strings before running
1578        await self.resolve_mcps()
1579
1580        # Re-bind tools to chat_model so LLM sees MCP tools
1581        # Heaven tools have .base_tool, MCP tools (StructuredTool) are already BaseTool
1582        all_base = []
1583        for t in self.tools:
1584            if hasattr(t, 'base_tool'):
1585                all_base.append(t.base_tool)
1586            else:
1587                all_base.append(t)
1588        self.chat_model = self.chat_model.bind_tools(all_base)
1589
1590        # Fire BEFORE_RUN hook
1591        self._fire_hook(HookPoint.BEFORE_RUN, prompt=prompt or "")
1592
1593        try:
1594            
1595            # Start with history messages
1596            conversation_history = self.history.messages.copy() if self.history else []
1597            
1598            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1599                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1600            # check if self.History's last message is HumanMessage. If it is, continue and if not AND prompt is not None, add prompt to self.history as a HumanMessage
1601            # if not (conversation_history and isinstance(conversation_history[-1], HumanMessage)) and prompt is not None:
1602                
1603            #     conversation_history.append(HumanMessage(content=prompt))
1604            
1605            
1606          
1607            # Check only the last message for agent command
1608            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1609                self._detect_agent_command(conversation_history[-1].content)
1610            # Check prompt for agent command
1611            if prompt is not None:
1612                self._detect_agent_command(prompt)
1613                if self.goal is None:
1614                    conversation_history.append(HumanMessage(content=prompt))
1615            if self.continuation_iterations != 0:
1616                self.current_iterations = 1
1617                self.max_iterations = self.continuation_iterations
1618                
1619                
1620            
1621              
1622            
1623            
1624            # print("\n=== Conversation History After System Check ===")
1625            # for i, msg in enumerate(conversation_history):
1626            #     print(f"Message {i}: {type(msg).__name__} - {msg.content[:100]}...")
1627            
1628            
1629
1630            
1631                        
1632            while self.current_iteration <= self.max_iterations:
1633                # Fire BEFORE_ITERATION hook
1634                self._fire_hook(HookPoint.BEFORE_ITERATION, iteration=self.current_iteration)
1635                # Refresh system prompt at the start of each iteration
1636                self.refresh_system_prompt()
1637                # Reset tool count for this iteration
1638                tool_call_count = 0
1639                
1640                # In normal chat mode, just use the last message
1641                # In agent mode, format with goals/tasks
1642                ### CHanged to solve continuations
1643                # next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
1644                next_prompt = self._format_agent_prompt() if (self.goal or self.continuation_prompt) else conversation_history[-1].content
1645                if self.goal or self.continuation_prompt:  # Add formatted prompt in agent mode or continuation
1646                    conversation_history.append(HumanMessage(content=next_prompt))
1647                # if self.goal:  # Only add formatted prompt in agent mode
1648                #     conversation_history.append(HumanMessage(content=next_prompt))
1649                    
1650                        
1651                # DUO Sidechain
1652               
1653                if (
1654                    self.duo_enabled
1655                    and len(conversation_history) > 2        # <- require at least 3 messages
1656                    and isinstance(conversation_history[-1], HumanMessage)
1657                ):
1658                    # 1. Pull off the original human message
1659                    original = conversation_history[-1]
1660                    original_sys = conversation_history[0]
1661                    try:
1662                        # 2. Replace it with your Duo-specific system prompt
1663                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
1664                        conversation_history[0] = duo_sys
1665                        
1666                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming. **YOU MUST ANSWER SOLELY IN `NodeGraphXTN6` language.**"
1667                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
1668                        # 3. Invoke Duo
1669                        duo = await self.duo_chat.ainvoke(conversation_history)
1670                    finally:
1671                        # 4. Restore the original system prompt
1672                        conversation_history[0] = original_sys
1673                        conversation_history[-1] = original
1674                    if duo:
1675                        # 5. Extract the duo’s content
1676                        
1677                        duo_content = duo.content
1678                        
1679                    
1680                        # 6. Rebuild the human message so your duo content is prepended
1681                        new_human = HumanMessage(
1682                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nConsider this silently before responding. Do not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
1683                        )
1684                
1685                        # 7. Replace the last entry with your new combined message
1686                        conversation_history[-1] = new_human
1687                  
1688                # Invoke model for a response
1689                # call the generator agent
1690                response = await self.chat_model.ainvoke(conversation_history)
1691                if heaven_main_callback:
1692                    heaven_main_callback(response)
1693
1694                # print(f"\nResponse: {response}\n")
1695                
1696                # Check if the response.content is empty but tool call info is present.
1697                # GOOGLE ONLY
1698                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
1699                # if (
1700                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
1701                #     and isinstance(response, AIMessage)
1702                #     and (
1703                #         response.tool_calls                                  # standard field
1704                #         or response.additional_kwargs.get("tool_calls")      # legacy field
1705                #     )
1706                # ):
1707                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1708                
1709                #     # --- THE FIX ---
1710                #     # Append the ORIGINAL response object. LangChain needs its structure.
1711                #     conversation_history.append(response)
1712                # # Only add text blocks from response, NOT the whole response
1713                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1714                                    
1715                                    
1716                                    # Clean response for conversation_history
1717                                    if isinstance(response.content, list):
1718                                        # Extract only text, ignore thinking blocks
1719                                        text_content = []
1720                                        for item in response.content:
1721                                            if isinstance(item, str):
1722                                                text_content.append(item)
1723                                            elif isinstance(item, dict) and item.get('type') == 'text':
1724                                                text_content.append(item.get('text', ''))
1725                                        
1726                                        # Create cleaned response with simple string content
1727                                        cleaned_response = AIMessage(
1728                                            content=' '.join(text_content),  # Simple string, not list!
1729                                            additional_kwargs=response.additional_kwargs,
1730                                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
1731                                        )
1732                                        conversation_history.append(cleaned_response)
1733                                    else:
1734                                        conversation_history.append(response)
1735                # Append whole LangChain AIMessage — never destructure it.
1736                # Splitting content blocks into separate AIMessages drops tool_use blocks
1737                # and breaks the inner tool loop (model says "calling tool" then stops).
1738                else:
1739                    conversation_history.append(response)
1740                    # Still process text for agent goal tracking
1741                    if isinstance(response.content, list):
1742                        text_parts = [block.get('text', '') for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
1743                        if text_parts:
1744                            self._process_agent_response('\n'.join(text_parts))
1745                    elif isinstance(response.content, str):
1746                        self._process_agent_response(response.content)
1747                    
1748    
1749                # ── Tool-call loop (matches uni_api pattern) ──
1750                # response is already in conversation_history from above.
1751                # LangChain populates .tool_calls on AIMessage automatically.
1752                current_response = response
1753
1754                while getattr(current_response, 'tool_calls', None) and tool_call_count < self.max_tool_calls:
1755                    # Execute ALL tool calls from this response
1756                    for tc in current_response.tool_calls:
1757                        if tool_call_count >= self.max_tool_calls:
1758                            break
1759
1760                        # Extract name/args/id — LangChain uses {name, args, id}
1761                        tool_name = tc.get('name', '')
1762                        tool_args = tc.get('args', tc.get('input', {}))
1763                        tool_id = tc.get('id', '')
1764
1765                        # Find matching tool
1766                        matching_tools = [
1767                            t for t in self.tools
1768                            if (t.base_tool.name.lower() if hasattr(t, 'base_tool') else t.name.lower()) == tool_name.lower()
1769                        ]
1770                        if not matching_tools:
1771                            print(f"No matching tool found for {tool_name}")
1772                            continue
1773
1774                        tool = matching_tools[0]
1775
1776                        # Fire BEFORE_TOOL_CALL hook
1777                        self._fire_hook(HookPoint.BEFORE_TOOL_CALL,
1778                            iteration=self.current_iteration,
1779                            tool_name=tool_name, tool_args=tool_args)
1780
1781                        # Execute tool (throttle to prevent CPU spin with fast models)
1782                        await asyncio.sleep(0.1)
1783                        try:
1784                            if hasattr(tool, 'base_tool'):
1785                                tool_result = await tool._arun(**tool_args)
1786                            else:
1787                                from langchain_core.runnables import RunnableConfig
1788                                tool_result = ToolResult(output=str(
1789                                    await tool._arun(config=RunnableConfig(), **tool_args)
1790                                ))
1791                        except Exception as e:
1792                            tool_result = ToolResult(error=str(e))
1793
1794                        # Fire AFTER_TOOL_CALL hook
1795                        self._fire_hook(HookPoint.AFTER_TOOL_CALL,
1796                            iteration=self.current_iteration,
1797                            tool_name=tool_name, tool_args=tool_args,
1798                            tool_result=tool_result)
1799
1800                        # Build ToolMessage content
1801                        if tool_result.error:
1802                            tool_message_content = str(tool_result.error)
1803                        elif tool_result.base64_image:
1804                            tool_message_content = str(tool_result.base64_image)
1805                        else:
1806                            tool_message_content = str(tool_result.output)
1807
1808                        # Append ToolMessage — LangChain handles provider formatting
1809                        conversation_history.append(
1810                            ToolMessage(content=tool_message_content, tool_call_id=tool_id)
1811                        )
1812
1813                        if heaven_main_callback:
1814                            heaven_main_callback(conversation_history[-1])
1815
1816                        # Check blocked / special tools
1817                        if tool.name == "WriteBlockReportTool":
1818                            blocked = True
1819                        if tool.name == "TaskSystemTool":
1820                            self._handle_task_system_tool(tool_args)
1821
1822                        tool_call_count += 1
1823
1824                    # If blocked, generate report and exit
1825                    if blocked:
1826                        block_report_md = self.create_block_report()
1827                        if block_report_md:
1828                            if self._current_extracted_content is None:
1829                                self._current_extracted_content = {}
1830                            self._current_extracted_content["block_report"] = block_report_md
1831                            self.history.agent_status = self.save_status()
1832                        break
1833
1834                    if tool_call_count >= self.max_tool_calls:
1835                        conversation_history.append(
1836                            AIMessage(content=(
1837                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
1838                                f"reached for iteration {self.current_iteration}. "
1839                                "If I received the same error every time, I should use "
1840                                "WriteBlockReportTool next... Waiting for next iteration."
1841                            ))
1842                        )
1843                        break
1844
1845                    # Call API again — get next response
1846                    current_response = await self.chat_model.ainvoke(conversation_history)
1847                    conversation_history.append(current_response)
1848                    if heaven_main_callback:
1849                        heaven_main_callback(current_response)
1850
1851                    # Refresh system prompt
1852                    self.refresh_system_prompt()
1853                    sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
1854                    if self.config.system_prompt != conversation_history[sys_msg_idx].content:
1855                        conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
1856
1857                    # Process text for agent goal tracking
1858                    if isinstance(current_response.content, list):
1859                        text_parts = [b.get('text', '') for b in current_response.content if isinstance(b, dict) and b.get('type') == 'text']
1860                        if text_parts:
1861                            self._process_agent_response('\n'.join(text_parts))
1862                    elif isinstance(current_response.content, str):
1863                        self._process_agent_response(current_response.content)
1864
1865                    # while condition re-checks current_response.tool_calls
1866    
1867                # Process the agent response if in agent mode
1868                if self.goal and isinstance(response, AIMessage):
1869                    self._process_agent_response(response.content)
1870                if blocked:
1871                    break
1872                # Fire AFTER_ITERATION hook
1873                self._fire_hook(HookPoint.AFTER_ITERATION, iteration=self.current_iteration)
1874                # Increment iteration count and break if the goal is met
1875                self.current_iteration += 1
1876
1877                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
1878                    self.history.agent_status = self.save_status()
1879                    break
1880
1881            # Fire AFTER_RUN hook
1882            self._fire_hook(HookPoint.AFTER_RUN, iteration=self.current_iteration)
1883            self.history.messages = conversation_history
1884            # Save history and get potentially new history_id
1885            try:
1886                
1887                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
1888                # print(f"Agent name: {self.name}")
1889                # print(f"Current history: {self.history}")
1890                self.history.agent_status = self.save_status()
1891                saved_history_id = self.history.save(self.name)
1892                # print("===DEBUG AFTER SAVE ATTEMPT===")
1893                self.look_for_particular_tool_calls()
1894                return {
1895                    "history": self.history,
1896                    "history_id": saved_history_id,
1897                    "agent_name": self.name,
1898                    "agent_status": self.history.agent_status  # Add this
1899                }
1900            except Exception as save_error:
1901                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
1902                print(f"Error type: {type(save_error)}")
1903                print(f"Error message: {str(save_error)}")
1904                # Log the error but don't fail the run
1905                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
1906                
1907                return {
1908                    "history": self.history,
1909                    "history_id": getattr(self.history, 'history_id', "No history ID"),
1910                    "agent_name": self.name,
1911                    "save_error": str(save_error),
1912                    "agent_status": self.save_status()  # Add this here too
1913                }
1914    
1915        except Exception as e:
1916            # Fire ON_ERROR hook
1917            self._fire_hook(HookPoint.ON_ERROR, error=e)
1918            raise RuntimeError(f"Agent run failed: {str(e)}") from e
1919
1920
1921    #### Might not be needed because we can potentially add an observer on the History to look for new additions and render them when agent is constructed with streamlit = True, and make sampling_loop set streamlit = True on the agent it initializes
1922    async def streamlit_run(self, output_callback: Callable[[BaseMessage], None], tool_output_callback: Callable[[ToolResult, str], None], heaven_main_callback: Optional[Callable[[Any], None]] = None, prompt: Optional[str] = None):
1923        # Start with existing messages
1924        if self.history is not None:
1925            messages = self.history.messages  # These are already BaseMessage objects!
1926        else:
1927            messages = []
1928        self._sanitize_history()
1929
1930        # Resolve MCP tools before running (same as run_langchain)
1931        await self.resolve_mcps()
1932
1933        tool_log_path = "/tmp/tool_debug.log"
1934        with open(tool_log_path, 'a') as f:
1935            f.write("\n\nStarting tool debug log\n")
1936
1937        with open('/tmp/streamlit_debug.log', 'a') as f:
1938            f.write("\n\nStarting streamlit_run")
1939            f.write(f"\nHistory length: {len(self.history.messages)}")
1940            f.write(f"\nHistory messages: {self.history.messages}")
1941            # f.write(f"\nCurrent callbacks: {output_callback}, {tool_output_callback}")
1942        try:
1943            # # If input is a string, convert to messages
1944            # if isinstance(messages, str):
1945            #     messages = [
1946            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1947            #         HumanMessage(content=messages)
1948            #     ]
1949            #     messages = [m for m in messages if m]  # Remove None values
1950            
1951            # # Create a copy of messages to avoid modifying the original
1952            # conversation_history = messages.copy()
1953            # Start with history messages
1954            conversation_history = self.history.messages.copy() if self.history else []
1955            # If history is just a string, convert to messages
1956            # if isinstance(conversation_history, str):
1957            #     conversation_history = [
1958            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1959            #         HumanMessage(content=conversation_history)
1960            #     ]
1961            #     conversation_history = [m for m in conversation_history if m]  # Remove None values
1962            
1963            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1964                # Condition 1: There is no conversation history with a SystemMessage.
1965                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1966            # Added for streamlit agent selector
1967            elif conversation_history[0].content != self.config.system_prompt:
1968                # Condition 2: The first element is a SystemMessage, but it doesn't have the current system prompt.
1969                conversation_history[0] = SystemMessage(content=self.config.system_prompt)
1970            with open('/tmp/streamlit_debug.log', 'a') as f:
1971                f.write("\n=== Conversation History After System Check ===")
1972                for i, msg in enumerate(conversation_history):
1973                    f.write(f"\nMessage {i}: {type(msg).__name__} - {msg.content[:100]}...")
1974            
1975            # Check for agent command, but don't require it
1976            # for message in conversation_history:
1977            #     if isinstance(message, HumanMessage):
1978            #         self._detect_agent_command(message.content)
1979            # Check only the last message for agent command
1980            if prompt is not None:
1981                input_msg = HumanMessage(content=prompt)
1982                conversation_history.append(input_msg)
1983              
1984            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1985                self._detect_agent_command(conversation_history[-1].content)
1986            
1987
1988            ###### if not self.goal, render the original messages
1989            # Initial render if no agent goal
1990            # if not self.goal:
1991            #     for message in conversation_history:
1992            #         if isinstance(message, ToolMessage):
1993            #             # Convert to ToolResult for UI
1994            #             tool_result = ToolResult(
1995            #                 output=message.content,
1996            #                 error=None if not message.additional_kwargs.get("is_error") else message.content,
1997            #                 base64_image=message.additional_kwargs.get("base64_image"),
1998            #                 system=message.additional_kwargs.get("system")
1999            #             )
2000            #             tool_callback(tool_result)
2001            #         else:
2002            #             # Regular message rendering
2003            #             output_callback(message)
2004                        
2005            while self.current_iteration <= self.max_iterations:
2006                # Reset tool count for this iteration
2007                tool_call_count = 0
2008                
2009                # In normal chat mode, just use the last message
2010                # In agent mode, format with goals/tasks
2011                next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
2012                if self.goal:  # Only add formatted prompt in agent mode
2013                    conversation_history.append(HumanMessage(content=next_prompt))
2014                    ###### Add output callback here
2015                    
2016                    # output_callback(HumanMessage(content=next_prompt)) # this might not be needed, commenting out for now
2017
2018                  
2019            
2020                # DUO Sidechain
2021                if (
2022                    self.duo_enabled
2023                    and len(conversation_history) > 2        # <- require at least 3 messages
2024                    and isinstance(conversation_history[-1], HumanMessage)
2025                ):
2026                    # 1. Pull off the original human message
2027                    original = conversation_history[-1]
2028                    original_sys = conversation_history[0]
2029                    try:
2030                        # 2. Replace it with your Duo-specific system prompt
2031                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
2032                        conversation_history[0] = duo_sys
2033                        
2034                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming."
2035                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
2036                        # 3. Invoke Duo
2037                        duo = await self.duo_chat.ainvoke(conversation_history)
2038                    finally:
2039                        # 4. Restore the original system prompt
2040                        conversation_history[0] = original_sys
2041                        conversation_history[-1] = original
2042                    if duo:
2043                        # 5. Extract the duo’s content
2044                        
2045                        duo_content = duo.content
2046                        
2047                    
2048                        # 6. Rebuild the human message so your duo content is prepended
2049                        new_human = HumanMessage(
2050                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nDo not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
2051                        )
2052                
2053                        # 7. Replace the last entry with your new combined message
2054                        conversation_history[-1] = new_human
2055                
2056                # Invoke model for a response
2057                # logger = logging.getLogger(__name__)
2058                # logger.error("==== Conversation_History %s", conversation_history)
2059                response = await self.chat_model.ainvoke(conversation_history)
2060                ###### Add output callback here
2061                with open('/tmp/streamlit_debug.log', 'a') as f:
2062                    f.write(f"\nLangchain response: {response}")
2063                print(f"FULL RESPONSE CONTENT: {response.content}")
2064                print(f"RESPONSE TYPE: {type(response.content)}")
2065                if isinstance(response.content, list):
2066                    for i, block in enumerate(response.content):
2067                        print(f"Block {i}: {block}")
2068                if heaven_main_callback:
2069                    heaven_main_callback(response)
2070                output_callback(response)
2071                # GOOGLE ONLY
2072                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2073                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2074                # if (
2075                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2076                #     and isinstance(response, AIMessage)
2077                #     and (
2078                #         response.tool_calls                                  # standard field
2079                #         or response.additional_kwargs.get("tool_calls")      # legacy field
2080                #     )
2081                # ):
2082                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2083                #     # --- THE FIX ---
2084                #     # Append the ORIGINAL response object. LangChain needs its structure.
2085                #     conversation_history.append(response)
2086                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2087                                        
2088                    # Clean response for conversation_history
2089                    if isinstance(response.content, list):
2090                        # Extract only text, ignore thinking blocks
2091                        text_content = []
2092                        for item in response.content:
2093                            if isinstance(item, str):
2094                                text_content.append(item)
2095                            elif isinstance(item, dict) and item.get('type') == 'text':
2096                                text_content.append(item.get('text', ''))
2097                        
2098                        # Create cleaned response with simple string content
2099                        cleaned_response = AIMessage(
2100                            content=' '.join(text_content),  # Simple string, not list!
2101                            additional_kwargs=response.additional_kwargs,
2102                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
2103                        )
2104                        conversation_history.append(cleaned_response)
2105                    else:
2106                        conversation_history.append(response)
2107                    
2108                # Only add text blocks from response, NOT the whole response
2109                # Extract text blocks if content is a list of blocks
2110                # else:
2111                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2112                    if isinstance(response.content, list):
2113                        thinking_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2114                        if thinking_content:
2115                            message3 = AIMessage(content=thinking_content)
2116                            conversation_history.append(message3)
2117                            # if heaven_main_callback:
2118                            #     heaven_main_callback(message3)
2119                        text_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
2120                        if text_content:
2121                            text_message = AIMessage(content=text_content)
2122                            conversation_history.append(text_message)
2123                            self._process_agent_response(text_content)
2124                            # if heaven_main_callback:
2125                            #     heaven_main_callback(text_message)
2126                    elif isinstance(response.content, str):
2127                        response_message = AIMessage(content=response.content)
2128                        conversation_history.append(response_message)
2129                        self._process_agent_response(response.content)
2130                        # if heaven_main_callback:
2131                        #         heaven_main_callback(response_message)
2132                        
2133
2134                # # Extract tool calls from the response
2135                # print("\nDEBUG: Examining response for tool calls:")
2136                # print(f"Response: {response}")
2137                # print(f"Content: {response.content}")
2138                # print(f"Additional kwargs: {response.additional_kwargs}")
2139    
2140                tool_calls = []
2141                try:
2142                    if hasattr(response, 'tool_calls'):
2143                        print("Found tool_calls attribute")
2144                        tool_calls = response.tool_calls
2145                        print(f"Tool calls from attribute: {tool_calls}")
2146                    elif isinstance(response.content, list):
2147                        print("Found list content")
2148                        tool_calls = [
2149                            item for item in response.content 
2150                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2151                        ]
2152                        print(f"Tool calls from list: {tool_calls}")
2153                    elif 'tool_calls' in response.additional_kwargs:
2154                        print("Found tool_calls in additional_kwargs")
2155                        tool_calls = response.additional_kwargs['tool_calls']
2156                        print(f"Tool calls from kwargs: {tool_calls}")
2157                except Exception as e:
2158                    print(f"Error examining response: {e}")
2159    
2160                print(f"Extracted tool_calls: {tool_calls}")
2161    
2162                # Handle tool calls up to max_tool_calls limit
2163                current_tool_calls = tool_calls
2164    
2165                while current_tool_calls and tool_call_count < self.max_tool_calls:
2166                    new_tool_calls = []
2167                    for tool_call in current_tool_calls:
2168                        if tool_call_count >= self.max_tool_calls:
2169                            break
2170                        try:
2171                            # Try OpenAI style
2172                            if 'function' in tool_call:
2173                                tool_name = tool_call['function']['name']
2174                                tool_args = eval(tool_call['function']['arguments'])
2175                                tool_id = tool_call.get('id', '')
2176                            # Try Anthropic style
2177                            elif 'name' in tool_call:
2178                                tool_name = tool_call['name']
2179                                tool_args = tool_call.get('input', tool_call.get('args', {}))
2180                                tool_id = tool_call.get('id', '')
2181                            # Fallback
2182                            else:
2183                                tool_name = tool_call.get('name', '')
2184                                tool_args = tool_call.get('args', {})
2185                                tool_id = tool_call.get('id', '')
2186    
2187                            # Find matching tool
2188                            matching_tools = [
2189                                tool for tool in self.tools
2190                                if (tool.base_tool.name.lower() if hasattr(tool, 'base_tool') else tool.name.lower()) == tool_name.lower()
2191                            ]
2192
2193                            if matching_tools:
2194                                tool = matching_tools[0]
2195
2196                                # tool_result = await tool._arun(**tool_args)
2197                               
2198                                # Execute the tool and get its result
2199                                # Throttle to prevent CPU spin with fast models (MiniMax)
2200                                await asyncio.sleep(0.1)
2201                                if hasattr(tool, 'base_tool'):
2202                                    tool_result = await tool._arun(**tool_args)
2203                                else:
2204                                    from langchain_core.runnables import RunnableConfig
2205                                    config = RunnableConfig()
2206                                    raw_result = await tool._arun(config=config, **tool_args)
2207                                    tool_result = ToolResult(output=str(raw_result))
2208                                with open('/tmp/streamlit_debug.log', 'a') as f:
2209                                    f.write(f"\Tool result: {tool_result}")
2210                                # except ToolError as e:
2211                                #     # The error is already formatted with tool name in _arun
2212                                #     tool_result = ToolResult(error=str(e))
2213                                #     with open(tool_log_path, 'a') as f:
2214                                #         f.write(f"\nTool error: {e}\n")
2215                                
2216    
2217                                print("\n=== BEFORE ADDING TOOL MESSAGES ===")
2218                                for i, msg in enumerate(conversation_history):
2219                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2220    
2221                                # Handle tool messages based on provider
2222                                # if self.config.provider == ProviderEnum.OPENAI or self.config.provider == ProviderEnum.GROQ:
2223                                if self.config.provider in [ProviderEnum.OPENAI, ProviderEnum.GROQ, ProviderEnum.DEEPSEEK]:
2224                                    # OpenAI requires tool calls in additional_kwargs
2225                                    conversation_history.append(
2226                                        AIMessage(
2227                                            content="",  # OpenAI doesn't want content for tool calls
2228                                            additional_kwargs={
2229                                                "tool_calls": [{
2230                                                    "id": tool_id,
2231                                                    "type": "function",
2232                                                    "function": {
2233                                                        "name": tool_name,
2234                                                        "arguments": json.dumps(tool_args)
2235                                                        # str(tool_args)
2236                                                    }
2237                                                }]
2238                                            }
2239                                        )
2240                                    )
2241                                    # Then add the tool result
2242                                    tool_message_content = str(tool_result.error) if tool_result.error else str(tool_result.output) # changed for openai
2243                                    tool_result_message = ToolMessage(
2244                                        content=str(tool_message_content),
2245                                        tool_call_id=tool_id,
2246                                        additional_kwargs={
2247                                            "name": tool_name,
2248                                            "function": {"name": tool_name, "arguments": json.dumps(tool_args)
2249                                            # str(tool_args)
2250                                            }
2251                                        }
2252                                    )
2253                                    conversation_history.append(tool_result_message)
2254                                #####
2255                                elif self.config.provider == ProviderEnum.GOOGLE:
2256                                    # Gemini (via LangChain) expects a ToolMessage directly associated
2257                                    # with the tool_call_id from the preceding AIMessage's tool_calls.
2258                                    # We primarily need to construct the ToolMessage with the result.
2259                                    # The AIMessage that contained the tool_call request should already
2260                                    # be in the history from the model's previous turn.
2261                                    
2262                                    if tool_result.error:
2263                                        tool_message_content = str(tool_result.error)
2264                                        # Optional: Keep system prompt swap logic if needed for Gemini too
2265                                        # sys_msg_idx = next((i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage)), 0)
2266                                        # conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2267                                    elif tool_result.base64_image:
2268                                         # Gemini can handle images in ToolMessages if formatted correctly
2269                                        tool_message_content=[
2270                                            {
2271                                                'type': 'image_url', # Gemini prefers image_url format
2272                                                'image_url': f"data:image/png;base64,{tool_result.base64_image}"
2273                                            },
2274                                            {"type": "text", "text": "Image from tool execution."} # Context is helpful
2275                                        ]
2276                                    else:
2277                                        tool_message_content = str(tool_result.output)
2278            
2279                                    tool_message = ToolMessage(
2280                                        content=tool_message_content,
2281                                        tool_call_id=tool_id,
2282                                        name=tool_name
2283                                    )
2284                                    conversation_history.append(tool_message)
2285                                #####
2286                                else:
2287                                    # Anthropic and others use the original format
2288                                    conversation_history.append(
2289                                        AIMessage(
2290                                            content=[{
2291                                                "type": "tool_use",
2292                                                "id": tool_id,
2293                                                "name": tool_name,
2294                                                "input": tool_args
2295                                            }]
2296                                        )
2297                                    )
2298                                    
2299                                    # THIS BLOCK NEEDS TO BE IF TOOLRESULT ELSE TOOLERROR
2300                                    if tool_result.error:
2301                                        tool_message_content = str(tool_result.error)
2302                                            # Store current system message position for reference
2303                                        sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2304                                        # Swap to tool debug mode
2305                                        conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2306                                    elif tool_result.base64_image:
2307                                        tool_message_content=[{
2308                                            'type': 'image', 
2309                                            'source': {
2310                                                'type': 'base64', 
2311                                                'media_type': 'image/png', 
2312                                                'data': f"{tool_result.base64_image}"
2313                                                }
2314                                            },
2315                                            {"type": "text", "text": "Describe this image."}
2316                                        ]
2317                                    else:
2318                                        # We know tool_result.output exists because _arun guarantees either
2319                                        # error or output will be set
2320                                        tool_message_content = str(tool_result.output)
2321
2322                                    conversation_history.append(
2323                                        ToolMessage(
2324                                            # content=str(tool_result.output),  # Just the output string
2325                                            content=tool_message_content,
2326                                            tool_call_id=tool_id,
2327                                            name=tool_name
2328                                        )
2329                                    )
2330                                    # treverse the conversation_history and remove any all screen shot tool message 
2331                                    # except for the current one we just added (fix > 200,000 token issue)
2332                                    # for item in conversation_history[-2::-1]:
2333                                    #     if len(str(item.content)) > 100000:
2334                                    #         item.content="Removed old image that is no longer needed"
2335                                ### Unindented these 2 tabs
2336                                with open(tool_log_path, 'a') as f:
2337                                    f.write(f"\nAbout to call tool_output_callback with result: {tool_result.output}\n")
2338                                    f.write(f"tool_id: {tool_id}\n")
2339                                
2340                                tool_output_callback(tool_result, tool_id)
2341                                if heaven_main_callback:
2342                                    heaven_main_callback(conversation_history[-1]) # the tool message
2343                                with open(tool_log_path, 'a') as f:
2344                                    f.write("After tool_callback\n")
2345                                   
2346    
2347                                print("\n=== AFTER ADDING TOOL MESSAGES ===")
2348                                for i, msg in enumerate(conversation_history):
2349                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2350    
2351                                # Get AI's response about the tool result
2352                                # print("\n=== GETTING AI RESPONSE ABOUT TOOL RESULT ===")
2353                                with open(tool_log_path, 'a') as f:
2354                                    f.write(f"\nAI should be called next...\n")
2355                                    f.write(f"Coversation_History:\n    {conversation_history}\n")
2356                                result_response = await self.chat_model.ainvoke(conversation_history)
2357                                
2358                                sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2359                                conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
2360                                if heaven_main_callback:
2361                                        heaven_main_callback(result_response)
2362                                if result_response:
2363                                    with open(tool_log_path, 'a') as f:
2364                                        f.write(f"\nGot AI response after tool: {result_response.content}\n")
2365                                # print(f"===Result response===:\n\n{result_response}\n\n===/result response===")
2366                                ###### Add output callback here
2367                                
2368    
2369                                # if (
2370                                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2371                                #     and isinstance(result_response, AIMessage)
2372                                #     and (
2373                                #         result_response.tool_calls                                  # standard field
2374                                #         or result_response.additional_kwargs.get("tool_calls")      # legacy field
2375                                #     )
2376                                # ):
2377                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage) and not result_response.content and (result_response.tool_calls or result_response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2378                                if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2379                                    
2380                                    
2381                                    # Clean response for conversation_history
2382                                    if isinstance(result_response.content, list):
2383                                        # Extract only text, ignore thinking blocks
2384                                        text_content = []
2385                                        for item in result_response.content:
2386                                            if isinstance(item, str):
2387                                                text_content.append(item)
2388                                            elif isinstance(item, dict) and item.get('type') == 'text':
2389                                                text_content.append(item.get('text', ''))
2390                                        
2391                                        # Create cleaned response with simple string content
2392                                        cleaned_result_response = AIMessage(
2393                                            content=' '.join(text_content),  # Simple string, not list!
2394                                            additional_kwargs=result_response.additional_kwargs,
2395                                            tool_calls=result_response.tool_calls if hasattr(result_response, 'tool_calls') else []
2396                                        )
2397                                        conversation_history.append(cleaned_result_response)
2398                                    else:
2399                                        conversation_history.append(result_response)
2400                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2401                                #     # --- THE FIX ---
2402                                #     # Append the ORIGINAL response object. LangChain needs its structure.
2403                                #     conversation_history.append(result_response)
2404                                #     # self._process_agent_response(result_response)
2405                                #     output_callback(result_response)
2406                                #     # if heaven_main_callback:
2407                                #     #     heaven_main_callback(result_response)
2408                                # else:
2409                                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2410                                    if isinstance(result_response.content, list):
2411                                        thinking_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2412                                        if thinking_content:
2413                                            message3 = AIMessage(content=thinking_content)
2414                                            conversation_history.append(message3)
2415                                        text_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'text']
2416                                        if text_content:
2417                                            message = AIMessage(content=text_content)
2418                                            conversation_history.append(message)
2419                                            self._process_agent_response(text_content)
2420                                            output_callback(message)  # Pass the AIMessage object
2421                                            # if heaven_main_callback:
2422                                            #     heaven_main_callback(message)
2423                                        tool_use_content = [block for block in result_response.content if block.get('type') == 'tool_use']
2424                                        if tool_use_content:
2425                                            message2 = AIMessage(content=tool_use_content)
2426                                            output_callback(message2)
2427                                            # if heaven_main_callback:
2428                                            #     heaven_main_callback(message2)
2429                                    elif isinstance(result_response.content, str):
2430                                        message = AIMessage(content=result_response.content)
2431                                        conversation_history.append(message)
2432                                        self._process_agent_response(result_response.content)
2433                                        output_callback(message)  # Pass the AIMessage object
2434                                        # if heaven_main_callback:
2435                                        #     heaven_main_callback(message)
2436                                
2437                                # print("\n=== CONVERSATION HISTORY AFTER AI RESPONSE ABOUT TOOL ===")
2438                                # for i, msg in enumerate(conversation_history):
2439                                #     print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2440
2441                                # This may be redundant
2442                                # Process the AI's commentary if in agent mode
2443                                if isinstance(result_response, AIMessage):
2444                                    self._process_agent_response(result_response.content)
2445
2446                                # Now check whether the result_response includes new tool calls
2447                                new_calls = []
2448                                try:
2449                                    if hasattr(result_response, 'tool_calls'):
2450                                        new_calls = result_response.tool_calls
2451                                    elif isinstance(result_response.content, list):
2452                                        new_calls = [
2453                                            item for item in result_response.content 
2454                                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2455                                        ]
2456                                    elif 'tool_calls' in result_response.additional_kwargs:
2457                                        new_calls = result_response.additional_kwargs['tool_calls']
2458                                except Exception as e:
2459                                    print(f"Error examining result_response: {e}")
2460    
2461                                if new_calls:
2462                                    new_tool_calls.extend(new_calls)
2463    
2464                                tool_call_count += 1
2465                                if tool_call_count >= self.max_tool_calls:
2466                                    # NEW: Handle any pending tool calls that won't be processed
2467                                    # if current_tool_calls:
2468                                    #     # There are still tool calls queued that we're about to abandon
2469                                    #     for pending_tool in current_tool_calls:
2470                                    #         # Extract the tool info (handle different formats)
2471                                    #         if 'function' in pending_tool:
2472                                    #             tool_id = pending_tool.get('id', '')
2473                                    #             tool_name = pending_tool['function']['name']
2474                                    #         else:
2475                                    #             tool_id = pending_tool.get('id', '')
2476                                    #             tool_name = pending_tool.get('name', '')
2477                                            
2478                                    #         # Inject dummy ToolMessage for each orphaned call
2479                                    #         dummy_msg = ToolMessage(
2480                                    #             content="Error: The underlying system stopped this tool call from completing. It was interrupted. Once the user responds, the tool count will be reset.",
2481                                    #             tool_call_id=tool_id,
2482                                    #             name=tool_name
2483                                    #         )
2484                                    #         conversation_history.append(dummy_msg)
2485                                    #### NEW
2486                                        # Add a message informing the AI that max tool count was reached
2487                                    break_message = f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}. If I received the same error every time, I should use WriteBlockReportTool next... Waiting for next iteration."
2488                                    break_ai_message = AIMessage(content=break_message)
2489                                    conversation_history.append(break_ai_message)
2490                                    output_callback(break_message)
2491                                    if heaven_main_callback:
2492                                        heaven_main_callback(break_ai_message)
2493                                    # Clear the tool queue for this iteration
2494                                    current_tool_calls = []
2495                                    print(f"Maximum tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}")
2496                                    break  # Exit tool loop and continue to next iteration
2497                            else:
2498                                print(f"No matching tool found for {tool_name}")
2499                        except Exception as e:
2500                            print(f"Error processing tool call: {tool_call}")
2501                            print(f"Error details: {e}")
2502                            continue
2503                    # Prepare to process any new tool calls that came in the follow-up response
2504                    current_tool_calls = new_tool_calls
2505    
2506                
2507                
2508                # Process the agent response if in agent mode
2509                if self.goal and isinstance(response, AIMessage):
2510                    self._process_agent_response(response.content)
2511                    
2512                    # print(f"\nDEBUG TASK STATE: current_task={self.current_task}, current_iteration={self.current_iteration}, max_iterations={self.max_iterations}, task_list={self.task_list}")
2513    
2514                # Increment iteration count and break if the goal is met
2515                self.current_iteration += 1
2516    
2517                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
2518                    break
2519    
2520            # Update final history and return
2521            print("\n=== FINAL HISTORY ===")
2522            for i, msg in enumerate(conversation_history):
2523                print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2524            self.history.messages = conversation_history
2525            self.look_for_particular_tool_calls()
2526            self.history.messages = conversation_history
2527            # Save history and get potentially new history_id
2528            try:
2529                
2530                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
2531                # print(f"Agent name: {self.name}")
2532                # print(f"Current history: {self.history}")
2533                self.history.agent_status = self.save_status()
2534                saved_history_id = self.history.save(self.name)
2535                # print("===DEBUG AFTER SAVE ATTEMPT===")
2536                self.look_for_particular_tool_calls()
2537                return {
2538                    "history": self.history,
2539                    "history_id": saved_history_id,
2540                    "agent_name": self.name,
2541                    "agent_status": self.history.agent_status  # Add this
2542                }
2543            except Exception as save_error:
2544                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
2545                print(f"Error type: {type(save_error)}")
2546                print(f"Error message: {str(save_error)}")
2547                # Log the error but don't fail the run
2548                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
2549                
2550                return {
2551                    "history": self.history,
2552                    "history_id": getattr(self.history, 'history_id', "No history ID"),
2553                    "agent_name": self.name,
2554                    "save_error": str(save_error),
2555                    "agent_status": self.save_status()  # Add this here too
2556                }
2557            return self.history
2558    
2559        except Exception as e:
2560            raise RuntimeError(f"Agent run failed: {str(e)}") from e
2561
2562
2563
2564
2565
2566  ### UNI
2567
2568    
2569    def _prepare_tools_for_uni_api(self) -> List[Dict[str, Any]]:
2570        """Convert HEAVEN and LangChain tools to OpenAI format for uni-api"""
2571        if not self.tools:
2572            return []
2573        
2574        openai_tools = []
2575        for tool in self.tools:
2576            try:
2577                if hasattr(tool, 'get_openai_function'):
2578                    # HEAVEN tool with get_openai_function method
2579                    openai_tool = tool.get_openai_function()
2580                    openai_tools.append(openai_tool)
2581                elif hasattr(tool, 'args_schema') and hasattr(tool, 'name'):
2582                    # LangChain StructuredTool - convert schema to OpenAI format
2583                    from langchain_core.utils.function_calling import convert_to_openai_function
2584                    function_schema = convert_to_openai_function(tool)
2585                    # Wrap in proper OpenAI tool format
2586                    openai_tool = {
2587                        "type": "function",
2588                        "function": function_schema
2589                    }
2590                    openai_tools.append(openai_tool)
2591                else:
2592                    print(f"Unknown tool type, skipping: {tool}")
2593            except Exception as e:
2594                tool_name = getattr(tool, 'name', str(type(tool)))
2595                print(f"Error converting tool {tool_name} to OpenAI format: {e}")
2596        
2597        return openai_tools
2598
2599    async def _execute_tool_calls_uni(self, tool_calls: List[Dict[str, Any]], tool_output_callback: Optional[Callable] = None) -> List[Dict[str, Any]]:
2600        """Execute tool calls in uni-api (OpenAI) format and return tool messages"""
2601        import json
2602        
2603        tool_messages = []
2604        
2605        for tool_call in tool_calls:
2606            try:
2607                # DEBUG: Print the exact tool_call object
2608                print(f"🔧 DEBUG: Processing tool_call: {json.dumps(tool_call, indent=2)}")
2609                
2610                # Extract tool info from OpenAI format
2611                tool_id = tool_call["id"]
2612                tool_name = tool_call["function"]["name"]
2613                tool_args = json.loads(tool_call["function"]["arguments"])
2614                
2615                print(f"🔧 DEBUG: Extracted tool_id='{tool_id}', tool_name='{tool_name}'")
2616                
2617                # Find matching tool (HEAVEN or LangChain)
2618                matching_tools = []
2619                for tool in self.tools:
2620                    if hasattr(tool, 'base_tool'):
2621                        # HEAVEN tool
2622                        if tool.base_tool.name.lower() == tool_name.lower():
2623                            matching_tools.append(tool)
2624                    elif hasattr(tool, 'name'):
2625                        # LangChain tool (StructuredTool)
2626                        if tool.name.lower() == tool_name.lower():
2627                            matching_tools.append(tool)
2628                
2629                if matching_tools:
2630                    tool = matching_tools[0]
2631                    
2632                    # Execute the tool differently based on type
2633                    if hasattr(tool, 'base_tool'):
2634                        # HEAVEN tool - returns ToolResult
2635                        tool_result = await tool._arun(**tool_args)
2636                        if tool_output_callback:
2637                            tool_output_callback(tool_result, tool_id)
2638                        tool_content = str(tool_result.error) if tool_result.error else str(tool_result.output)
2639                    else:
2640                        # LangChain/MCP tool - _arun needs config kwarg
2641                        from langchain_core.runnables import RunnableConfig
2642                        config = RunnableConfig()
2643                        raw_result = await tool._arun(config=config, **tool_args)
2644                        if tool_output_callback:
2645                            tool_output_callback(raw_result, tool_id)
2646                        tool_content = str(raw_result)
2647                    
2648                    tool_message = {
2649                        "role": "tool",
2650                        "tool_call_id": tool_id,
2651                        "name": tool_name,
2652                        "content": tool_content
2653                    }
2654                    
2655                    tool_messages.append(tool_message)
2656                    
2657                    # Check if TaskSystemTool was called
2658                    if tool_name == "TaskSystemTool":
2659                        self._handle_task_system_tool(tool_args)
2660                    # Check if WriteBlockReportTool was called
2661                    if tool_name == "WriteBlockReportTool":
2662                        # Mark that we're blocked - this will be checked by the caller
2663                        self.blocked = True
2664                        # Generate block report
2665                        block_report_md = self.create_block_report()
2666                        if block_report_md:
2667                            # Follow the established pattern: modify _current_extracted_content
2668                            if self._current_extracted_content is None:
2669                                self._current_extracted_content = {}
2670                            
2671                            # Add the block report
2672                            self._current_extracted_content["block_report"] = block_report_md
2673                            
2674                            # Update agent_status using the established save_status method
2675                            self.history.agent_status = self.save_status()
2676                    
2677                else:
2678                    # Tool not found
2679                    error_message = {
2680                        "role": "tool", 
2681                        "tool_call_id": tool_id,
2682                        "content": f"Error: Tool '{tool_name}' not found"
2683                    }
2684                    tool_messages.append(error_message)
2685            
2686            except Exception as e:
2687                # Tool execution error
2688                error_message = {
2689                    "role": "tool",
2690                    "tool_call_id": tool_call["id"],
2691                    "content": f"Error executing tool: {str(e)}"
2692                }
2693                tool_messages.append(error_message)
2694        
2695        return tool_messages
2696
2697    def _cleanse_dangling_tool_calls(self, uni_conversation_history: List[Dict], langchain_conversation_history: List[BaseMessage], reason: str = ""):
2698        """Remove dangling tool_calls from the last message in uni_conversation_history only."""
2699        if uni_conversation_history and uni_conversation_history[-1].get("tool_calls"):
2700            if "MULTIPLE_TOOL_CALLS" in reason and len(uni_conversation_history[-1]["tool_calls"]) > 1:
2701                print(f"🔧 CLEANSING {reason}: Keeping only first of {len(uni_conversation_history[-1]['tool_calls'])} tool_calls")
2702                uni_conversation_history[-1]["tool_calls"] = [uni_conversation_history[-1]["tool_calls"][0]]
2703            elif reason == "MAX_TOOL_CALLS":
2704                print(f"🔧 CLEANSING {reason}: Removing {len(uni_conversation_history[-1]['tool_calls'])} dangling tool_calls")
2705                uni_conversation_history[-1] = {
2706                    "role": "assistant", 
2707                    "content": uni_conversation_history[-1].get("content", "")
2708                }
2709
2710    # async def run_on_uni_api(
2711    #     self, 
2712    #     prompt: Optional[str] = None,
2713    #     output_callback: Optional[Callable] = None,
2714    #     tool_output_callback: Optional[Callable] = None,
2715    #     heaven_main_callback: Optional[Callable] = None
2716    # ) -> Dict[str, Any]:
2717    #     """
2718    #     Run agent using uni-api instead of LangChain providers.
2719    #     Uses parallel uni/langchain conversation tracking.
2720    #     """
2721    #     # Store callbacks for tool execution
2722    #     self._current_output_callback = output_callback
2723    #     self._current_tool_callback = tool_output_callback
2724        
2725    #     # Convert existing history to uni-api format
2726    #     uni_conversation_history = self.history.to_uni_messages()
2727    #     langchain_conversation_history = self.history.messages.copy()
2728        
2729    #     # Ensure system message is present and correct
2730    #     if not uni_conversation_history or uni_conversation_history[0]["role"] != "system":
2731    #         uni_conversation_history.insert(0, {
2732    #             "role": "system", 
2733    #             "content": self.config.system_prompt
2734    #         })
2735    #         langchain_conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
2736    #     elif uni_conversation_history[0]["content"] != self.config.system_prompt:
2737    #         uni_conversation_history[0]["content"] = self.config.system_prompt
2738    #         langchain_conversation_history[0] = SystemMessage(content=self.config.system_prompt)
2739        
2740    #     # Add new user prompt if provided
2741    #     if prompt:
2742    #         uni_conversation_history.append({"role": "user", "content": prompt})
2743    #         langchain_conversation_history.append(HumanMessage(content=prompt))
2744            
2745    #         # Detect agent commands
2746    #         self._detect_agent_command(prompt)
2747        
2748    #     # Prepare tools for uni-api
2749    #     openai_tools = self._prepare_tools_for_uni_api()
2750        
2751    #     # Initialize blocked flag
2752    #     self.blocked = False
2753        
2754    #     # Main iteration loop
2755    #     while self.current_iteration <= self.max_iterations:
2756    #         tool_call_count = 0
2757            
2758    #         # CLEANSE DANGLING TOOL_CALLS: Before adding agent mode prompt, clean up any dangling tool_calls
2759    #         self._cleanse_dangling_tool_calls(uni_conversation_history, langchain_conversation_history, "BEFORE_AGENT_PROMPT")
2760            
2761    #         # Handle agent mode formatting
2762    #         if self.goal:
2763    #             agent_prompt = self._format_agent_prompt()
2764    #             uni_conversation_history.append({"role": "user", "content": agent_prompt})
2765    #             langchain_conversation_history.append(HumanMessage(content=agent_prompt))
2766            
2767    #         # DUO sidechain logic (if enabled)
2768    #         if (self.duo_enabled and len(uni_conversation_history) > 2 
2769    #             and uni_conversation_history[-1]["role"] == "user"):
2770                
2771    #             original_user = langchain_conversation_history[-1]
2772    #             original_sys = langchain_conversation_history[0]
2773                
2774    #             try:
2775    #                 # Process with DUO using LangChain messages
2776    #                 duo_sys = SystemMessage(content=self.duo_system_prompt)
2777    #                 lc_messages = langchain_conversation_history.copy()
2778    #                 lc_messages[0] = duo_sys
2779                    
2780    #                 new_content = f"===ENTERING CHALLENGER MODE===\\n\\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\\n\\n{original_user.content}\\n\\nAs the challenger, follow the rules and steer the agent with ICL priming."
2781    #                 lc_messages[-1] = HumanMessage(content=new_content)
2782                    
2783    #                 duo_response = await self.duo_chat.ainvoke(lc_messages)
2784                    
2785    #                 if duo_response:
2786    #                     # Update both histories with DUO injection
2787    #                     enhanced_content = f"{original_user.content}\\n\\n```\\n===Challenger Injection===\\n\\nDo not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\\n\\n{duo_response.content}\\n\\n===/Challenger Injection===\\n```\\n\\n"
2788    #                     uni_conversation_history[-1]["content"] = enhanced_content
2789    #                     langchain_conversation_history[-1] = HumanMessage(content=enhanced_content)
2790                    
2791    #             finally:
2792    #                 pass
2793            
2794    #         # Build payload for uni-api
2795    #         payload = {
2796    #             "max_tokens": getattr(self.config, 'max_tokens', 4000),
2797    #             "temperature": getattr(self.config, 'temperature', 0.7)
2798    #         }
2799            
2800    #         if openai_tools:
2801    #             payload["tools"] = openai_tools
2802    #             payload["tool_choice"] = "auto"
2803    #             payload["parallel_tool_calls"] = False  # Fix for uni-api multiple tool calls bug
2804            
2805    #         try:
2806    #             # Call uni-api through unified_chat
2807    #             result = self.unified_chat.invoke_uni_api(
2808    #                 model=self.config.model,
2809    #                 uni_messages=uni_conversation_history,
2810    #                 **payload
2811    #             )
2812                
2813    #             # Extract token usage for context window management
2814    #             if "usage" in result and isinstance(result["usage"], dict):
2815    #                 self.context_window_config.update_from_uni_api(result["usage"])
2816    #                 # Store token usage in history metadata
2817    #                 if not hasattr(self.history, 'metadata'):
2818    #                     self.history.metadata = {}
2819    #                 self.history.metadata["last_token_usage"] = result["usage"]
2820    #                 self.history.metadata["current_tokens"] = result["usage"].get("total_tokens", 0)
2821    #             else:
2822    #                 # Fallback: use tiktoken estimation for workspace
2823    #                 from .utils.token_counter import count_tokens_in_messages
2824    #                 workspace_tokens = count_tokens_in_messages(langchain_conversation_history, self.context_window_config.model)
2825    #                 self.context_window_config.update_workspace_tokens(workspace_tokens)
2826    #                 if not hasattr(self.history, 'metadata'):
2827    #                     self.history.metadata = {}
2828    #                 self.history.metadata["current_tokens"] = self.context_window_config.current_tokens
2829                
2830    #             assistant_message = result["choices"][0]["message"]
2831                
2832    #             # DEBUG: Print the exact assistant_message object from uni-api
2833    #             print(f"🔍 DEBUG: assistant_message from uni-api: {json.dumps(assistant_message, indent=2)}")
2834                
2835    #             # CLEANSE MULTIPLE TOOL_CALLS BEFORE APPENDING using our method
2836    #             temp_history = [assistant_message]
2837    #             self._cleanse_dangling_tool_calls(temp_history, [], "MULTIPLE_TOOL_CALLS")
2838    #             assistant_message = temp_history[0]
2839                
2840    #             # Handle tool calls vs regular response
2841    #             if assistant_message.get("tool_calls"):
2842    #                 print(f"🔍 TOOL CALLS DETECTED: {len(assistant_message['tool_calls'])} tool calls")
2843    #                 # For tool calls, ensure content is empty string instead of null for OpenAI API compatibility
2844    #                 if assistant_message.get("content") is None:
2845    #                     assistant_message["content"] = ""
2846                    
2847    #                 # CLEANSE MULTIPLE TOOL_CALLS: Only process the FIRST tool call at a time
2848    #                 if len(assistant_message["tool_calls"]) > 1:
2849    #                     print(f"🔧 CLEANSING MULTIPLE TOOL_CALLS: Keeping only first of {len(assistant_message['tool_calls'])} tool_calls")
2850    #                     assistant_message["tool_calls"] = [assistant_message["tool_calls"][0]]
2851                    
2852    #                 # Store original for potential cleansing
2853    #                 original_assistant_message = assistant_message.copy()
2854                    
2855    #                 # For tool calls, add to uni history AFTER applying workaround
2856    #                 uni_conversation_history.append(assistant_message)
2857                    
2858    #                 # Add the AIMessage with tool_calls to langchain history (OpenAI style)
2859    #                 tool_call_ai_message = AIMessage(
2860    #                     content="",  # OpenAI doesn't want content for tool calls
2861    #                     additional_kwargs={
2862    #                         "tool_calls": assistant_message["tool_calls"]
2863    #                     }
2864    #                 )
2865    #                 langchain_conversation_history.append(tool_call_ai_message)
2866                    
2867    #                 # Trigger callbacks for tool call message
2868    #                 if output_callback:
2869    #                     output_callback(tool_call_ai_message)
2870                    
2871    #                 if heaven_main_callback:
2872    #                     heaven_main_callback(assistant_message)
2873                    
2874    #                 while assistant_message.get("tool_calls") and tool_call_count < self.max_tool_calls:
2875    #                     # DEBUG: Print the exact assistant_message with tool_calls
2876    #                     print(f"🔧 DEBUG: PROCESSING ASSISTANT MESSAGE: {json.dumps(assistant_message, indent=2)}")
2877    #                     print(f"🔧 DEBUG: TOOL_CALLS ARRAY: {json.dumps(assistant_message['tool_calls'], indent=2)}")
2878                        
2879    #                     # Execute tools and get tool messages
2880    #                     tool_messages = await self._execute_tool_calls_uni(assistant_message["tool_calls"], tool_output_callback)
2881                        
2882    #                     # DEBUG: Print the exact tool_messages we created - COMPLETE JSON
2883    #                     print(f"🔧 TOOL_MESSAGES WE CREATED - COMPLETE JSON:")
2884    #                     print(json.dumps(tool_messages, indent=2))
2885    #                     print(f"🔧 END TOOL_MESSAGES")
2886                        
2887    #                     # Add tool messages to both histories
2888    #                     uni_conversation_history.extend(tool_messages)
2889    #                     for tool_msg in tool_messages:
2890    #                         lc_tool_msg = ToolMessage(
2891    #                             content=tool_msg["content"],
2892    #                             tool_call_id=tool_msg["tool_call_id"]
2893    #                         )
2894    #                         langchain_conversation_history.append(lc_tool_msg)
2895                        
2896    #                     # Check if we're blocked (WriteBlockReportTool was called)
2897    #                     if self.blocked:
2898    #                         break
2899                        
2900    #                     # DEBUG: Print exact conversation history length before uni-api call
2901    #                     print(f"🚨 BEFORE UNI-API CALL: uni_conversation_history has {len(uni_conversation_history)} messages")
2902                        
2903    #                     # Get AI response to tool results
2904    #                     tool_result = self.unified_chat.invoke_uni_api(
2905    #                         model=self.config.model,
2906    #                         uni_messages=uni_conversation_history,
2907    #                         **payload
2908    #                     )
2909                        
2910    #                     assistant_message = tool_result["choices"][0]["message"]
2911                        
2912    #                     # DEBUG: Print the COMPLETE assistant_message object from uni-api - NO TRUNCATION
2913    #                     print(f"🔍 UNI-API ASSISTANT MESSAGE - COMPLETE JSON:")
2914    #                     print(json.dumps(assistant_message, indent=2))
2915    #                     print(f"🔍 END UNI-API ASSISTANT MESSAGE")
2916                        
2917                        
2918    #                     # CAPTURE CANCELLED TOOL_CALLS before cleansing
2919    #                     cancelled_tools = []
2920    #                     if tool_call_count >= self.max_tool_calls and assistant_message.get("tool_calls"):
2921    #                         for tc in assistant_message["tool_calls"]:
2922    #                             tool_name = tc["function"]["name"]
2923    #                             tool_args = tc["function"]["arguments"]
2924    #                             cancelled_tools.append(f"{tool_name}({tool_args})")
2925    #                         print(f"🔧 CLEANSING MAX_TOOL_CALLS: Removing {len(assistant_message['tool_calls'])} dangling tool_calls from assistant_message")
2926    #                         assistant_message = {
2927    #                             "role": "assistant", 
2928    #                             "content": assistant_message.get("content", "")
2929    #                         }
2930    #                     self._cleanse_dangling_tool_calls(assistant_message, [], "MULTIPLE_TOOL_CALLS")
2931                        
2932                        
2933    #                     # DEBUG: Extract tool_call_ids from the assistant message
2934    #                     if assistant_message.get("tool_calls"):
2935    #                         print(f"🔍 TOOL_CALL_IDS FROM UNI-API:")
2936    #                         for i, tc in enumerate(assistant_message["tool_calls"]):
2937    #                             print(f"  Tool Call {i}: ID = '{tc['id']}'")
2938    #                         print(f"🔍 END TOOL_CALL_IDS")
2939                        
2940    #                     # Handle None content from uni-api (happens with tool calls)
2941    #                     if assistant_message.get("content") is None:
2942    #                         assistant_message["content"] = ""
2943                        
2944                        
2945    #                     # Add cleansed message to both histories
2946    #                     uni_conversation_history.append(assistant_message)
2947    #                     lc_message = self.history.from_uni_messages([assistant_message]).messages[0]
2948    #                     langchain_conversation_history.append(lc_message)
2949                        
2950    #                     # Trigger callbacks
2951    #                     if output_callback:
2952    #                         output_callback(lc_message)
2953                        
2954    #                     if heaven_main_callback:
2955    #                         heaven_main_callback(assistant_message)
2956                        
2957    #                     tool_call_count += 1
2958                        
2959    #                     if tool_call_count >= self.max_tool_calls:
2960    #                         break_message = {
2961    #                             "role": "assistant",
2962    #                             "content": f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) reached after agent mode iteration {self.current_iteration}. I tried to call [{', '.join(cancelled_tools)}] but they were cancelled by the system. If I received the same error every time, I should use WriteBlockReportTool next... Waiting for next agent mode iteration."
2963    #                         }
2964    #                         uni_conversation_history.append(break_message)
2965    #                         lc_break_message = self.history.from_uni_messages([break_message]).messages[0]
2966    #                         langchain_conversation_history.append(lc_break_message)
2967                            
2968    #                         if output_callback:
2969    #                             output_callback(lc_break_message)
2970                            
2971    #                         break
2972                
2973    #             else:
2974    #                 # Regular response (no tool calls)
2975    #                 uni_conversation_history.append(assistant_message)
2976    #                 if assistant_message.get("content") is None:
2977    #                     assistant_message["content"] = ""
2978    #                 lc_message = self.history.from_uni_messages([assistant_message]).messages[0]
2979    #                 langchain_conversation_history.append(lc_message)
2980                    
2981    #                 # Trigger callbacks
2982    #                 if output_callback:
2983    #                     output_callback(lc_message)
2984                    
2985    #                 if heaven_main_callback:
2986    #                     heaven_main_callback(assistant_message)
2987                
2988    #             # Process agent response if in agent mode
2989    #             if self.goal and assistant_message.get("content"):
2990    #                 self._process_agent_response(assistant_message["content"])
2991                
2992    #             # CLEANSE DANGLING TOOL_CALLS: Before moving to next iteration, clean up any dangling tool_calls
2993    #             self._cleanse_dangling_tool_calls(uni_conversation_history, langchain_conversation_history, "MAX_TOOL_CALLS")
2994                
2995    #             # Check for completion
2996    #             self.current_iteration += 1
2997                
2998    #             if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or self.blocked:
2999    #                 break
3000            
3001    #         except Exception as e:
3002    #             error_msg = f"uni-api request failed: {str(e)}"
3003    #             print(error_msg)
3004    #             raise RuntimeError(error_msg)
3005        
3006    #     # Save final LangChain history
3007    #     self.history.messages = langchain_conversation_history
3008        
3009    #     # Save history
3010    #     try:
3011    #         self.history.agent_status = self.save_status()
3012    #         saved_history_id = self.history.save(self.name)
3013            
3014    #         return {
3015    #             "history": self.history,
3016    #             "history_id": saved_history_id,
3017    #             "agent_name": self.name,
3018    #             "agent_status": self.history.agent_status,
3019    #             "uni_api_used": True,
3020    #             "context_window_status": self.context_window_config.get_status(),
3021    #             "raw_response": result  # Include raw response for token extraction by AutoSummarizingAgent
3022    #         }
3023        
3024    #     except Exception as save_error:
3025    #         print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
3026    #         return {
3027    #             "history": self.history,
3028    #             "history_id": getattr(self.history, 'history_id', "No history ID"),
3029    #             "agent_name": self.name,
3030    #             "save_error": str(save_error),
3031    #             "agent_status": self.save_status(),
3032    #             "uni_api_used": True,
3033    #             "context_window_status": self.context_window_config.get_status(),
3034    #             "raw_response": result if 'result' in locals() else None
3035    #         }
3036
3037    async def run_on_uni_api(
3038        self,
3039        prompt: Optional[str] = None,
3040        output_callback: Optional[Callable] = None,
3041        tool_output_callback: Optional[Callable] = None,
3042        heaven_main_callback: Optional[Callable] = None,
3043    ) -> Dict[str, Any]:
3044        """
3045        Run agent using uni-api instead of LangChain providers.
3046        Enforces “one tool call at a time” without altering your
3047        original stop-message wording.
3048        """
3049        result = None
3050        # ---------- 0.  History bootstrap ----------
3051        self._current_output_callback = output_callback
3052        self._current_tool_callback = tool_output_callback
3053
3054        uni_conversation_history = self.history.to_uni_messages()
3055        langchain_conversation_history = self.history.messages.copy()
3056
3057        if not uni_conversation_history or uni_conversation_history[0]["role"] != "system":
3058            uni_conversation_history.insert(0, {"role": "system", "content": self.config.system_prompt})
3059            langchain_conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
3060        elif uni_conversation_history[0]["content"] != self.config.system_prompt:
3061            uni_conversation_history[0]["content"] = self.config.system_prompt
3062            langchain_conversation_history[0] = SystemMessage(content=self.config.system_prompt)
3063
3064        if prompt:
3065            uni_conversation_history.append({"role": "user", "content": prompt})
3066            langchain_conversation_history.append(HumanMessage(content=prompt))
3067            self._detect_agent_command(prompt)
3068
3069        # Resolve MCP tools before preparing for API
3070        await self.resolve_mcps()
3071
3072        openai_tools = self._prepare_tools_for_uni_api()
3073        self.blocked = False
3074
3075        # ---------- 1.  Iteration loop ----------
3076        while self.current_iteration <= self.max_iterations:
3077            tool_call_count = 0
3078            self._cleanse_dangling_tool_calls(
3079                uni_conversation_history, langchain_conversation_history, "MULTIPLE_TOOL_CALLS"
3080            )
3081
3082            if self.goal:
3083                agent_prompt = self._format_agent_prompt()
3084                uni_conversation_history.append({"role": "user", "content": agent_prompt})
3085                langchain_conversation_history.append(HumanMessage(content=agent_prompt))
3086
3087            payload = {
3088                "max_tokens": getattr(self.config, "max_tokens", 4000),
3089                "temperature": getattr(self.config, "temperature", 0.7),
3090                "parallel_tool_calls": False,
3091            }
3092            if openai_tools:
3093                payload["tools"] = openai_tools
3094                payload["tool_choice"] = "auto"
3095
3096            result = self.unified_chat.invoke_uni_api(
3097                model=self.config.model, uni_messages=uni_conversation_history, **payload
3098            )
3099            assistant_message = result["choices"][0]["message"]
3100
3101            # ---------- 2.  Single-tool clamp ----------
3102            self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3103
3104            # ---------- 3.  Tool-call branch ----------
3105            if assistant_message.get("tool_calls"):
3106                if assistant_message.get("content") is None:
3107                    assistant_message["content"] = ""
3108
3109                uni_conversation_history.append(assistant_message)
3110                langchain_conversation_history.append(
3111                    AIMessage(content="", additional_kwargs={"tool_calls": assistant_message["tool_calls"]})
3112                )
3113                if output_callback:
3114                    output_callback(langchain_conversation_history[-1])
3115                if heaven_main_callback:
3116                    heaven_main_callback(langchain_conversation_history[-1])
3117
3118                while assistant_message.get("tool_calls") and tool_call_count < self.max_tool_calls:
3119                    # execute the (single) tool call
3120                    tool_messages = await self._execute_tool_calls_uni(
3121                        assistant_message["tool_calls"], tool_output_callback
3122                    )
3123                    uni_conversation_history.extend(tool_messages)
3124                    for tm in tool_messages:
3125                        langchain_conversation_history.append(
3126                            ToolMessage(content=tm["content"], tool_call_id=tm["tool_call_id"])
3127                        )
3128
3129                    # Check if WriteBlockReportTool was called and auto-inject response
3130                    if self.blocked:
3131                        # Extract the required response from WriteBlockReportTool result
3132                        for tm in tool_messages:
3133                            if tm.get("name") == "WriteBlockReportTool":
3134                                response_msg = "I've created a block report and am waiting for the help I need"
3135                                
3136                                # Add to uni conversation layer
3137                                uni_conversation_history.append({
3138                                    "role": "assistant", 
3139                                    "content": response_msg
3140                                })
3141                                
3142                                # Add to langchain layer  
3143                                langchain_conversation_history.append(
3144                                    AIMessage(content=response_msg)
3145                                )
3146                                break
3147                        break
3148
3149                    tool_result = self.unified_chat.invoke_uni_api(
3150                        model=self.config.model, uni_messages=uni_conversation_history, **payload
3151                    )
3152                    assistant_message = tool_result["choices"][0]["message"]
3153                    
3154                    # ---------- 4.  Clamp again / MAX_TOOL_CALLS ----------
3155                    cancelled_tools = []  # Initialize to prevent NameError
3156                    if tool_call_count + 1 >= self.max_tool_calls and assistant_message.get("tool_calls"):
3157                        cancelled_tools = [
3158                            f"{tc['function']['name']}({tc['function']['arguments']})"
3159                            for tc in assistant_message["tool_calls"]
3160                        ]
3161                        assistant_message.pop("tool_calls", None)  # strip them but keep same dict
3162                    else:
3163                        self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3164
3165                    if assistant_message.get("content") is None:
3166                        assistant_message["content"] = ""
3167                    result = assistant_message
3168                    uni_conversation_history.append(assistant_message)
3169                    langchain_conversation_history.append(
3170                        self.history.from_uni_messages([assistant_message]).messages[0]
3171                    )
3172                    if output_callback:
3173                        output_callback(langchain_conversation_history[-1])
3174                    if heaven_main_callback:
3175                        heaven_main_callback(assistant_message)
3176
3177                    tool_call_count += 1
3178
3179                    if tool_call_count >= self.max_tool_calls:
3180                        break_message = {
3181                            "role": "assistant",
3182                            "content": (
3183                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
3184                                f"reached after agent mode iteration {self.current_iteration}. "
3185                                f"I tried to call [{', '.join(cancelled_tools)}] but they were "
3186                                "cancelled by the system. If I received the same error every time, "
3187                                "I should use WriteBlockReportTool next... Waiting for next agent "
3188                                "mode iteration."
3189                            ),
3190                        }
3191                        uni_conversation_history.append(break_message)
3192                        langchain_conversation_history.append(
3193                            self.history.from_uni_messages([break_message]).messages[0]
3194                        )
3195                        if output_callback:
3196                            output_callback(langchain_conversation_history[-1])
3197                        break
3198
3199            # ---------- 5.  Text-only branch ----------
3200            else:
3201                if assistant_message.get("content") is None:
3202                    assistant_message["content"] = ""
3203                uni_conversation_history.append(assistant_message)
3204                langchain_conversation_history.append(
3205                    self.history.from_uni_messages([assistant_message]).messages[0]
3206                )
3207                if output_callback:
3208                    output_callback(langchain_conversation_history[-1])
3209                if heaven_main_callback:
3210                    heaven_main_callback(langchain_conversation_history[-1])
3211
3212            # ---------- 6.  Agent-mode bookkeeping ----------
3213            if self.goal and assistant_message.get("content"):
3214                self._process_agent_response(assistant_message["content"])
3215
3216            self._cleanse_dangling_tool_calls(
3217                uni_conversation_history, langchain_conversation_history, "MAX_TOOL_CALLS"
3218            )
3219
3220            self.current_iteration += 1
3221            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or self.blocked:
3222                break
3223
3224        # ---------- 7.  Persist history ----------
3225        self.history.messages = langchain_conversation_history
3226        self.history.agent_status = self.save_status()
3227        saved_history_id = self.history.save(self.name)
3228
3229        return {
3230            "history": self.history,
3231            "history_id": saved_history_id,
3232            "agent_name": self.name,
3233            "agent_status": self.history.agent_status,
3234            "uni_api_used": True,
3235            "context_window_status": self.context_window_config.get_status(),
3236            "raw_response": result,
3237        }
3238
3239
3240  ###
3241    
3242  
3243    def _detect_agent_command(self, user_input: str):
3244        """Detects goal and iterations from user input."""
3245        match = re.search(r"agent goal=(.*?), iterations=(\d+)", user_input, re.IGNORECASE | re.DOTALL)
3246        if match:
3247            self.goal = match.group(1).strip()
3248            self.max_iterations = int(match.group(2))
3249            self.current_iteration = 1
3250            self.task_list = ["create_task_list"]
3251            self.current_task = "create_task_list"
3252            self.completed = False
3253    
3254    def _format_agent_prompt(self) -> str:
3255        """Formats the agentic prompt dynamically."""
3256        # Set defaults if not defined
3257        goal = self.goal if self.goal is not None else "There is no goal set!"
3258        current_task = self.current_task if self.current_task is not None else f"Update the task list for {self.goal}" # used to be "reason about what to do next"
3259        continuation_text = ""
3260        if hasattr(self, 'continuation_prompt') and self.continuation_prompt:
3261            continuation_text = f"{self.continuation_prompt}"
3262            if continuation_text == self.goal:
3263                continuation_text = ""
3264            self.continuation_prompt = None  # Clear it after use
3265        return (
3266        f"""
3267        # ===AGENT MODE IS ENGAGED===
3268        ## ❗ **Critical Instructions for Task Execution:**
3269        - Use the **TaskSystemTool** to manage your task list. Work on **one task at a time**.
3270
3271        1. **Create/update task list**: Call TaskSystemTool with operation="update_tasks", tasks=["task 1", "task 2", "task 3"]
3272           Then call TaskSystemTool with operation="complete_task", task_name="create_task_list" to complete the first task.
3273        **THEN DO THE TASKS IN THE TASK LIST, including any required tool calls. Each iteration allows {self.max_tool_calls} tool calls during agent mode!!! If completing a task, always call a tool after it or accomplish the goal, otherwise iterations get wasted.**
3274        2. **After completing any task**: Call TaskSystemTool with operation="complete_task", task_name="<the task you just finished>"
3275        THEN CONTINUE TO THE NEXT TASK.
3276        3. **When all tasks are done**: Call TaskSystemTool with operation="goal_accomplished"
3277        4. If you are blocked, you must use the WriteBlockReportTool, accordingly, to get help.
3278        """
3279        + (f"""You can also use these XML tag fence patterns when outputting specific deliverables to make the agent mode system capture them:[\n{self.additional_kw_instructions}\n]""" if self.additional_kw_instructions != "" else "")
3280        + f"""
3281        
3282        ---
3283        
3284        #### Notes
3285        - 1. An iteration is a LLM generation with oneOf: thinking, text, tool call, sequence of [thinking, text | thinking, text, tool call | text, tool call | tool call] -- up to {self.max_tool_calls} total tool calls per iteration. Any sequence ending with a tool call keeps current iteration alive. Any sequence ending with thinking or text terminates the iteration. 
3286        - 2. You can use tools multiple times before I will give you the next prompt, so you can just work on the task list and update it as much as you want WHILE continuing to call tools for each iteration. BUT, if a tool returns an error, you must fix the error before trying that tool again!!! 
3287        """
3288                +
3289                f"\n\#### Current State"
3290                f"\n\n- **Goal:** {goal}"
3291                f"\n\n- **Current Task List \"{self.task_list}\"**"
3292                f"\n\n- **Current Iteration:** {self.current_iteration} of {self.max_iterations}"
3293                f"\n\n- **Current Task:** {current_task}\n\n"
3294                f"\n\n{continuation_text}"
3295                + "\n"
3296        )
3297
3298    def _process_agent_response(self, response_content: Union[str, List[Any]]):
3299        ######## FUTURE FIX
3300        # Fix = examine all list elements (or recurse) instead of only index 0.
3301        ########
3302        # Handle AIMessage content formats
3303        # content_to_process = response_content
3304        def _flatten(obj):
3305
3306            if obj is None:
3307
3308                return ""
3309
3310            if isinstance(obj, str):
3311
3312                return obj
3313
3314            if isinstance(obj, dict):
3315
3316                return " ".join(_flatten(v) for v in obj.values())
3317
3318            if isinstance(obj, (list, tuple, set)):
3319
3320                return " ".join(_flatten(v) for v in obj)
3321
3322            if hasattr(obj, "content"):
3323
3324                return _flatten(obj.content)
3325
3326            return str(obj)
3327
3328
3329        content_to_process = _flatten(response_content)
3330        # if isinstance(content_to_process, list):
3331        #     # If it's a list of dicts with 'text' key
3332        #     if content_to_process and isinstance(content_to_process[0], dict):
3333        #         content_to_process = content_to_process[0].get('text', '')
3334        #     else:
3335        #         content_to_process = str(content_to_process[0]) if content_to_process else ""
3336        # elif not isinstance(content_to_process, str):
3337        #     content_to_process = str(content_to_process)      
3338        # Detect task list
3339        # Find all matches and take the last one since examples will be shown first
3340        task_list_matches = re.finditer(r"```update_task_list=\[(.*?)\]```", content_to_process, re.IGNORECASE)
3341        task_list_match = None
3342        for match in task_list_matches:
3343            task_list_match = match
3344            if task_list_match:
3345                raw_tasks = [task.strip().strip('"') for task in task_list_match.group(1).split(",")]
3346                if raw_tasks:  # Only update if we got tasks
3347                    self.task_list = raw_tasks
3348                    self.current_task = self.task_list[0]
3349                else:
3350                    self.task_list = []
3351                    self.current_task = None
3352        
3353 
3354
3355        # Detect task completion
3356        task_complete_match = re.search(r"```complete_task=(.*?)```", content_to_process, re.IGNORECASE)
3357        if task_complete_match:
3358            completed_task = task_complete_match.group(1).strip()
3359            self._complete_task(completed_task)
3360
3361        # Check for GOAL ACCOMPLISHED
3362        goal_accomplished_match = re.search(r"```GOAL ACCOMPLISHED```", content_to_process)
3363        if goal_accomplished_match:
3364            # Let the loop know to break, but don't modify current_task
3365            self.completed = True
3366            self.goal = None
3367          
3368        
3369    
3370        if self.additional_kws and self.additional_kw_instructions != "":
3371            if self._current_extracted_content is None:
3372                self._current_extracted_content = {}
3373        
3374            for kw in self.additional_kws:
3375                # kw_matches = re.finditer(f"```{kw}(.*?)```", content_to_process, re.DOTALL)
3376                md_pat  = re.finditer(
3377                    fr"```{kw}(.*?)```",
3378                    content_to_process,
3379                    re.DOTALL | re.IGNORECASE
3380                )
3381
3382                # ➋  XML-tag pattern  (new, collision-proof)
3383                xml_pat = re.finditer(
3384                    fr"<{kw}>(.*?)</{kw}>",
3385                    content_to_process,
3386                    re.DOTALL | re.IGNORECASE
3387                )
3388
3389                # Iterate over both result sets in order of appearance
3390                kw_matches = sorted(
3391                    list(md_pat) + list(xml_pat),
3392                    key=lambda m: m.start()
3393                )
3394                for match in kw_matches:
3395                    content = match.group(1).strip()
3396                    # Check history's agent status for existing entries
3397                    if (not self.history.agent_status or 
3398                        not self.history.agent_status.extracted_content or 
3399                        kw not in self.history.agent_status.extracted_content):
3400                        # First one just gets the keyword
3401                        self._current_extracted_content[kw] = content
3402                    else:
3403                        # If first occurrence exists, look for numbered entries
3404                        numbered_entries = [k for k in self.history.agent_status.extracted_content 
3405                                         if k.startswith(f"{kw}_")]
3406                        if numbered_entries:
3407                            # Find highest number used and increment
3408                            highest_num = max(int(k.split('_')[1]) for k in numbered_entries)
3409                            self._current_extracted_content[f"{kw}_{highest_num + 1}"] = content
3410                        else:
3411                            # First numbered entry should be 2
3412                            self._current_extracted_content[f"{kw}_2"] = content
3413        
3414
3415    
3416        self.history.agent_status = self.save_status()
3417
3418
3419    def _complete_task(self, completed_task: str):
3420        """Moves to the next task in the list after completion."""
3421        if completed_task in self.task_list:
3422            task_index = self.task_list.index(completed_task)
3423            # Remove the completed task
3424            self.task_list.pop(task_index)
3425            # Set next task
3426            if self.task_list:
3427                self.current_task = self.task_list[0]
3428            else:
3429                self.current_task = None
3430                self.goal = None
3431            self.history.agent_status = self.save_status()
3432
3433    def _handle_task_system_tool(self, tool_args: dict):
3434        """Process TaskSystemTool calls — updates task state from tool args."""
3435        op = tool_args.get("operation", "")
3436        if op == "update_tasks":
3437            tasks = tool_args.get("tasks", [])
3438            if tasks and isinstance(tasks, list):
3439                self.task_list = [str(t) for t in tasks]
3440                self.current_task = self.task_list[0]
3441        elif op == "complete_task":
3442            task_name = tool_args.get("task_name", "")
3443            if task_name:
3444                self._complete_task(task_name)
3445        elif op == "goal_accomplished":
3446            self.completed = True
3447            self.goal = None
3448        self.history.agent_status = self.save_status()
3449
3450    def save_status(self) -> AgentStatus:
3451        """Package current agent state into status object"""
3452        if self._current_extracted_content is not None:
3453            extracts = self._current_extracted_content
3454            return AgentStatus(
3455                goal=self.goal,
3456                task_list=self.task_list.copy(),
3457                current_task=self.current_task,
3458                completed=self.completed,
3459                extracted_content=extracts
3460            )
3461        else:
3462            
3463            return AgentStatus(
3464                goal=self.goal,
3465                task_list=self.task_list.copy(),
3466                current_task=self.current_task,
3467                completed=self.completed
3468            )
3469      
3470  
3471      
3472    def load_status(self, status: AgentStatus):
3473        """Load agent state from status object"""
3474        self.goal = status.goal
3475        self.task_list = status.task_list.copy()
3476        self.current_task = status.current_task
3477        self.max_iterations = status.max_iterations
3478        self.current_iteration = status.current_iteration
3479
3480
3481    async def continue_iterations(self, history_id: str, continuation_iterations: Optional[int] = 0, continuation_prompt: str = None):
3482        """Continue work from a saved history"""
3483        self.history = History.load_from_id(history_id)
3484        
3485        if self.history.agent_status:
3486            # Load goal, tasks, etc from status
3487            self.goal = self.history.agent_status.goal
3488            self.task_list = self.history.agent_status.task_list
3489            self.current_task = self.history.agent_status.current_task
3490            self.completed = False  # Reset completion for new run
3491            self.continuation_prompt = continuation_prompt if continuation_prompt is not None else ""
3492            self.continuation_iterations = continuation_iterations
3493            if self.goal is None:
3494                self.goal = self.continuation_prompt # this should mean goal is never none even if history has no goal
3495            if self.goal == "":
3496                self.goal = None
3497            return await self.run()  # Continue with current state
3498        else:
3499            raise ValueError("No agent status found in history")
3500  
3501    def reset(self):
3502        """Reset the agent's internal state."""
3503        self.goal = None
3504        self.task_list = []
3505        self.current_task = None
3506        self.max_iterations = None
3507        self.current_iteration = 0
3508
3509    def look_for_particular_tool_calls(self) -> None:
3510        """Hook for agents to process specific tool calls and their results"""
3511        pass
3512
3513    # def look_for_block_report(self) -> bool:
3514    #     for i, msg in enumerate(self.history.messages):
3515    #         if isinstance(msg, AIMessage) and isinstance(msg.content, list):
3516    #             for item in msg.content:
3517    #                 if isinstance(item, dict) and item.get('type') == 'tool_use':
3518    #                     if item.get('name') == "WriteBlockReportTool":
3519    #                         return True  # Exit early since we found the match
3520    #     return False
3521
3522    def create_block_report(self):
3523        # look up the json file
3524        block_report_path = "/tmp/block_report.json"
3525        
3526        # Check if the block report file was created
3527        if os.path.exists(block_report_path):            
3528            # Read and display the file contents
3529            with open(block_report_path, 'r') as f:
3530                report_data = json.load(f)
3531                print("Block Report Content:")
3532                for key, value in report_data.items():
3533                    print(f"  {key}: {value}")
3534                    # this is wrong, old code. i want to make it vars so i can create a markdown file
3535            
3536            # pull the stuff out and make it vars
3537           
3538            completed_tasks = report_data.get("completed_tasks", "N/A")
3539            current_task = report_data.get("current_task", "N/A")
3540            explanation = report_data.get("explanation", "No explanation provided.")
3541            blocked_reason = report_data.get("blocked_reason", "No blocked reason provided.")
3542            timestamp = report_data.get("timestamp", "Unknown timestamp")
3543          
3544            agent_task = self.current_task if self.current_task is not None else ""
3545            agent_goal = self.goal if self.goal is not None else ""
3546            truncated_goal = agent_goal[:200] + " <truncated for brevity - history_id file has full goal if you need to see it (this is unlikely)>" if len(agent_goal) > 200 else agent_goal
3547            # history_id = getattr(self.history, 'history_id', "No history ID") # this is impossible because of when the history_id is made. Instead, we provide it in the Hermes call metadata
3548            # inject to the places it should go in the markdown
3549            md_block_report = f"""
3550# 🛑🚧📃
3551## === BLOCKED REPORT ===
3552The agent encountered a blocking obstacle while working toward the goal.
3553
3554## 📝 Report Metadata  
3555- **⏰ Time of Report**: `{timestamp}`  
3556- **��� Report Filed By**: `{self.name}`   
3557
3558---
3559
3560## 🎯 Task Overview  
3561- **🏆 Goal**: `{truncated_goal}`  
3562- **✅ Self-reported Completed Tasks**: `{completed_tasks}`  
3563- **📌 Internally Tracked Current Task Value**: `{agent_task}`  
3564- **����� Agent's Perceived Current Task**: `{current_task}`. 
3565_(If there is a mismatch between this and the internally tracked task, the agent may not have fully followed tasking instructions.)_
3566
3567---
3568
3569## 🧐 Agent's Explanation  
3570**💬 The agent provided this explanation for the blockage:** 
3571```
3572{explanation}
3573```
3574
3575---
3576
3577## 🚨 Blocked Reason  
3578**⚠️ The agent explained the reason why it is blocked as follows:**  
3579```
3580{blocked_reason}
3581```
3582
3583## === /BLOCKED REPORT ===
3584
3585---
3586
3587## 📢 What To Do Next   
3588- 🛠️ **If you recognize the issue, offer potential solutions before asking the user for input.**
3589- 🧑‍����� **Check with the user to see if they already know how to resolve the issue.** 
3590- 🚦 **Do NOT use any tools before talking to the user.**  
3591  _(This should remain a HITL (Human-in-the-Loop) interaction.)_  
3592- ��🔁 **Once a potential solution route is found through discussion with the user,** activate a continuation call to continue the work (make sure to use the history_id provided).  
3593
3594"""
3595
3596        
3597        
3598            # Cleanup
3599            os.remove(block_report_path)
3600            return md_block_report
3601        else:
3602            print("Block report file not found.")
3603            return None

Base class for GOD Framework agents with task management.

BaseHeavenAgent( config: HeavenAgentConfig, unified_chat: heaven_base.unified_chat.UnifiedChat, max_tool_calls: int = 10, orchestrator: bool = False, history: Optional[heaven_base.memory.history.History] = None, history_id=None, system_prompt_suffix: Optional[str] = None, adk: Optional[bool] = False, duo_enabled: Optional[bool] = False, run_on_langchain: Optional[bool] = False, use_uni_api: Optional[bool] = False)
 719    def __init__(self, config: HeavenAgentConfig, unified_chat: UnifiedChat, max_tool_calls: int = 10, orchestrator: bool = False, history: Optional[History] = None, history_id = None, system_prompt_suffix: Optional[str] = None, adk: Optional[bool] = False, duo_enabled: Optional[bool] = False, run_on_langchain: Optional[bool] = False, use_uni_api: Optional[bool] = False):
 720        # Configure root logger to output to stdout
 721        logging.basicConfig(
 722            stream=sys.stdout,
 723            level=logging.DEBUG,  # Adjust the level as needed
 724            format='%(asctime)s %(levelname)s %(name)s: %(message)s'
 725            )
 726        logging.getLogger('watchdog').setLevel(logging.WARNING)
 727        
 728        if config is None:
 729            raise ValueError("config cannot be None")
 730        if unified_chat is None:
 731            raise ValueError("unified_chat cannot be None")
 732        self.use_uni_api = use_uni_api
 733        self.unified_chat = unified_chat
 734        self.known_config_paths = getattr(config, "known_config_paths", None)
 735        if self.known_config_paths is None:
 736            self.known_config_paths = []
 737        self.training_convo = [
 738            SystemMessage(content=""),
 739            HumanMessage(content="Hey I need to teach you how to use the tools correctly before we get started. Once you learn how to use this tool I'm going to show you, you'll know how to handle all of our tools. Essentially, it comes down to acknowledging the errors correctly. For example, can you use NetworkEditTool to write a file in tmp while having no file_content parameter, read the error that is sent back from the tool, and then adjust? file_content is an optional parameter in the NetworkEditTool, but it is actually required for certain operations, like the create command."),
 740            AIMessage(content=[{"text": "Following the instructions to first try without file_text and then fix it, I'll write utility code for an AST analyzer as an example.\n\nFirst attempt (should fail):", "type": "text"}]),
 741            AIMessage(content=[{"type": "tool_use", "id": "toolu_01BPHfZNPMY1tbnsRtRYmG5i", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py"}}]),
 742            ToolMessage(content="ERROR: Parameter `file_text` is required for command: create", tool_call_id="toolu_01BPHfZNPMY1tbnsRtRYmG5i"),
 743            AIMessage(content=[{"text": "As expected, we need to include file_text. Here's the proper attempt with the utility code:", "type": "text"}]),
 744            AIMessage(content=[{"type": "tool_use", "id": "toolu_01LS6GZDEpEqDVmU8qpvRa65", "name": "NetworkEditTool", "input": {"command": "create", "target_container": "creation_of_god", "path": "/tmp/ast_analyzer_utils.py", "file_text": "\"\"\"AST analyzer utilities for extracting concise code context and dependencies.\"\"\"\n\nimport ast\nfrom typing import Dict, List, Set, Union, Optional\nfrom dataclasses import dataclass\n\n@dataclass\nclass CodeContext:\n    \"\"\"Data class to hold analyzed code context.\"\"\"\n    imports: List[str]\n    dependencies: Dict[str, Set[str]]\n    call_graph: Dict[str, List[str]]\n\ndef parse_code_to_ast(code: str) -> ast.AST:\n    \"\"\"Parse code string into AST.\"\"\"\n    return ast.parse(code)\n\ndef extract_imports(tree: ast.AST) -> List[str]:\n    \"\"\"Extract all import statements from AST.\"\"\"\n    imports = []\n    for node in ast.walk(tree):\n        if isinstance(node, ast.Import):\n            for name in node.names:\n                imports.append(name.name)\n        elif isinstance(node, ast.ImportFrom):\n            module = node.module or ''\n            for name in node.names:\n                if name.name == '*':\n                    imports.append(f\"from {module} import *\")\n                else:\n                    imports.append(f\"from {module} import {name.name}\")\n    return sorted(imports)\n\ndef analyze_dependencies(tree: ast.AST) -> Dict[str, Set[str]]:\n    \"\"\"Analyze function/class dependencies.\"\"\"\n    dependencies = {}\n    current_func = None\n    \n    class DependencyVisitor(ast.NodeVisitor):\n        def visit_FunctionDef(self, node):\n            nonlocal current_func\n            old_func = current_func\n            current_func = node.name\n            if current_func not in dependencies:\n                dependencies[current_func] = set()\n            self.generic_visit(node)\n            current_func = old_func\n            \n        def visit_Call(self, node):\n            if current_func and isinstance(node.func, ast.Name):\n                dependencies[current_func].add(node.func.id)\n            self.generic_visit(node)\n    \n    DependencyVisitor().visit(tree)\n    return dependencies\n\ndef generate_call_graph(dependencies: Dict[str, Set[str]]) -> Dict[str, List[str]]:\n    \"\"\"Generate simplified call graph from dependencies.\"\"\"\n    return {\n        func: sorted(list(calls))\n        for func, calls in dependencies.items()\n    }\n\ndef analyze_code_context(code: str) -> CodeContext:\n    \"\"\"Main function to analyze code and return context.\"\"\"\n    tree = parse_code_to_ast(code)\n    imports = extract_imports(tree)\n    dependencies = analyze_dependencies(tree)\n    call_graph = generate_call_graph(dependencies)\n    \n    return CodeContext(\n        imports=imports,\n        dependencies=dependencies,\n        call_graph=call_graph\n    )"}}]),
 745            ToolMessage(content="File created successfully at: /tmp/ast_analyzer_utils.py", tool_call_id="toolu_01LS6GZDEpEqDVmU8qpvRa65"),
 746            AIMessage(content="The utility code has been written with the following key components..."),
 747            HumanMessage(content="Thanks! Now you know how to use tools correctly in our system. BTW, I deleted that file for you because this was just a learning experience. I also removed the NetworkEditTool for now. I'll equip it on you again later if you need it for the real goal we are working on. Now we're going to get on to the real goal..."),
 748            AIMessage(content="Perfect! I'm ready. Let me know how what else I can do for you.")
 749        ]
 750        self.known_config_paths = [str(path) for path in self.known_config_paths]
 751        self.hooks = config.hook_registry
 752        # Persona resolution: if persona set, load from SkillManager and extract components
 753        self.carton_identity = config.carton_identity
 754        # Set agent context for SkillTool so it uses agent-scoped SkillManager
 755        from .tool_utils.skill_utils import set_agent_context
 756        set_agent_context(config.name)
 757        if config.persona:
 758            try:
 759                from skill_manager.core import SkillManager
 760                sm = SkillManager(agent_id=config.name)
 761                persona_obj = sm.get_persona(config.persona)
 762                if persona_obj:
 763                    # Frame → prepend to system prompt
 764                    if persona_obj.frame and persona_obj.frame.strip():
 765                        config.system_prompt = persona_obj.frame + "\n\n" + config.system_prompt
 766                    # Skillset → use persona's if not explicitly set on config
 767                    if not config.skillset and persona_obj.skillset:
 768                        config.skillset = persona_obj.skillset
 769                    # MCP set → use persona's if not explicitly set on config
 770                    if not config.mcp_set and persona_obj.mcp_set:
 771                        config.mcp_set = persona_obj.mcp_set
 772                    # CartON identity → use persona's if not explicitly set
 773                    if not self.carton_identity and persona_obj.carton_identity:
 774                        self.carton_identity = persona_obj.carton_identity
 775            except Exception:
 776                pass  # Persona resolution is best-effort, never block agent startup
 777        # MCP set resolution: resolve set name → two paths per MCP:
 778        #   1. MCP IS in strata → load DIRECTLY on agent from strata config
 779        #   2. All MCPs → equip mcp-skill-* if exists (for context/instructions)
 780        # Pattern: hierarchical_summarize/flow.py::_get_summarizer_mcp_servers()
 781        self._mcp_skill_names = []
 782        if config.mcp_set:
 783            try:
 784                from strata.config import MCPServerList
 785                server_list = MCPServerList()
 786                set_server_names = server_list.get_set(config.mcp_set)
 787                if set_server_names:
 788                    from skill_manager.core import SkillManager
 789                    sm = SkillManager(agent_id=config.name)
 790                    for srv_name in set_server_names:
 791                        # Equip mcp-skill-* if it exists (context about the MCP)
 792                        skill_name = f"mcp-skill-{srv_name.lower()}"
 793                        skill = sm.get_skill(skill_name)
 794                        if skill:
 795                            self._mcp_skill_names.append(skill_name)
 796                            sm.equip(skill_name)
 797                        # If MCP IS in strata, load it directly on the agent
 798                        srv_config = server_list.get_server(srv_name)
 799                        if srv_config and srv_config.enabled:
 800                            if config.mcp_servers is None:
 801                                config.mcp_servers = {}
 802                            config.mcp_servers[srv_name] = {
 803                                "command": srv_config.command,
 804                                "args": srv_config.args,
 805                                "env": srv_config.env,
 806                                "transport": "stdio",
 807                            }
 808            except Exception as e:
 809                import logging as _log
 810                _log.getLogger(__name__).warning("MCP set resolution failed for %s: %s", config.mcp_set, e)
 811        # Auto-register default skill hooks when skillset is configured
 812        if config.skillset:
 813            from .hooks.default_hooks import register_skill_hooks
 814            register_skill_hooks(self.hooks, agent_name=config.name or "unnamed", skillset_name=config.skillset)
 815        self.max_tool_calls = max_tool_calls
 816        self.config = config
 817        self.name = config.name if config.name is not None else "unnamed_agent"
 818        # Convert and store LangChain config
 819        self.config_dict = config.to_langchain_config()
 820        # initialize status
 821        self.status = AgentStatus()
 822        self.continuation_prompt = ""
 823        self.continuation_iterations: int = 0
 824        # Store config tool classes
 825        self.config_tools = config.tools
 826        self.adk = adk
 827        # if not run_on_langchain:
 828        #     self.adk = True
 829        if run_on_langchain:
 830            self.adk = False
 831        if use_uni_api:
 832            self.adk = False
 833        self.additional_kws = config.additional_kws
 834        self.additional_kw_instructions = config.additional_kw_instructions
 835        # Instantiate the tools
 836        self.resolved_tools = self.resolve_tools()
 837        self.tools = []
 838        self.mcp_tool_strs = []  # Store MCP strings separately
 839        for tool in self.resolved_tools:
 840            if isinstance(tool, str) and tool.startswith("mcp__"):
 841                # MCP tool string reference - store separately for later resolution
 842                self.mcp_tool_strs.append(tool)
 843            elif isinstance(tool, (StructuredTool, BaseTool)):
 844                # Already a LangChain tool instance (e.g., from MCP)
 845                # Just add it directly - same as BaseHeavenTool.create() output
 846                self.tools.append(tool)
 847            elif hasattr(tool, 'create'):
 848                # BaseHeavenTool subclass - use its create method
 849                self.tools.append(tool.create(adk))
 850            else:
 851                print(f"Unknown tool type: {tool}, skipping")  
 852        # Filter and prepare provider-specific parameters
 853
 854        model_params = {
 855            'provider': config.provider,
 856            'model': config.model,
 857            'temperature': config.temperature,
 858            'max_tokens': config.max_tokens,
 859            'thinking_budget': config.thinking_budget
 860        }
 861        if config.extra_model_kwargs:
 862            model_params.update(config.extra_model_kwargs)
 863        # Create chat model internally using UnifiedChat
 864        self.chat_model = unified_chat.create(**model_params)
 865        self.resolve_duo(config)
 866        self.duo_params = config.duo_system_config.get_duo_params() if config.duo_system_config is not None else None
 867        self.duo_enabled = duo_enabled
 868        self.duo_chat = unified_chat.create(**self.duo_params)
 869        self.duo_system_prompt = config.duo_system_config.system_prompt if config.duo_system_config is not None else ""
 870        # Bind tools if available
 871        if self.tools and not self.adk:
 872        
 873            # For ADK, tools are already in the right format
 874            
 875            # Original LangChain binding logic
 876            if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 877                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 878                
 879                self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 880              
 881            elif config.provider in [ProviderEnum.GOOGLE]:
 882                raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 883                flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 884                fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 885                
 886                self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 887              
 888            else:
 889              
 890                self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 891                
 892                    
 893            # if config.provider in [ProviderEnum.OPENAI, ProviderEnum.DEEPSEEK]:
 894            #     # For these providers, use the schema generated by to_openai_function
 895                
 896            #     # Create the list of schema dictionaries using the classmethod
 897            #     # This list holds dictionaries like {'type': 'function', 'function': {...}}
 898            #     raw_provider_schemas = [
 899            #         # tool.__class__.to_openai_function()
 900            #         tool.get_openai_function()
 901            #         for tool in self.tools
 902            #     ]
 903    
 904            #     # Bind using bind_tools, passing the list of schema dictionaries
 905            #     # as per the apparent design of convert_to_openai_function's output usage
 906            #     print(f"Attempting bind_tools with {len(raw_provider_schemas)} generated schema dictionaries...")
 907            #     self.chat_model = self.chat_model.bind_tools(raw_provider_schemas)
 908            #     print(f"Binding via bind_tools with generated schemas successful.")
 909            # elif config.provider in [ProviderEnum.GOOGLE]:
 910            #     raw_provider_schemas = [tool.get_openai_function() for tool in self.tools]
 911            #     flattened_schemas = [generate_dereferenced_schema(schema) for schema in raw_provider_schemas]
 912            #     # Apply the fixer to remove empty object properties.
 913            #     fixed_schemas = [fix_empty_object_properties(schema) for schema in flattened_schemas]
 914            #     print(f"Attempting bind_tools with {len(fixed_schemas)} generated flattened schema dictionaries...")
 915            #     self.chat_model = self.chat_model.bind_tools(fixed_schemas)
 916            #     print("Binding via bind_tools with generated flattened schemas successful.")
 917            # else:
 918            #     self.chat_model = self.chat_model.bind_tools(self.to_base_tools())
 919                        
 920        # Agentic state
 921        
 922        self.goal: Optional[str] = None
 923        self.task_list: List[str] = []
 924        self.current_task: Optional[str] = None
 925        self.max_iterations: int = 1
 926        self.current_iteration: int = 1
 927        self.completed = False
 928        self._current_extracted_content = None
 929        self.orchestration_lists = f"""
 930        <HERMES SWITCHBOARD>
 931        The following Agents can be used in the `agent` arg of HermesTool:[
 932        {get_agent_modules()}]
 933        The following Tools can be used in the `additional_tools` arg of HermesTool:[
 934        {get_tool_modules()}]
 935        </HERMES SWITCHBOARD>
 936        """
 937        # Ensure known_config_paths is always a list
 938        self.configs_prompt = (
 939            f"""\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. These are your known configs: [\n{', '.join(self.known_config_paths)}]\n</CONFIG_LOCATIONS>\n"""
 940            if self.known_config_paths and any(self.known_config_paths) else
 941            """\n<CONFIG_LOCATIONS>\nBefore using a config with its proper tool, view the specs. Generally, configs are located at `~/.heaven/configs/`\n</CONFIG_LOCATIONS>\n"""
 942        )
 943
 944        self.tool_sysmsg = """You are the tool debugger. You always pay attention to the last tool error and fix it. 
 945## Common errors:
 946- missing input parameter
 947- wrong dict format for an input parameter
 948- treating required false differently from optional
 949- failing to follow instructions provided by an error (like: <some command> requires <these parameters>)
 950- other errors
 951
 952You must fix the error before proceeding."""
 953        # Get evolved system prompt if it exists
 954        self.system_prompt_evolved = self.config.get_system_prompt()
 955        # Store History in agent
 956        self.history: Optional[History] = history
 957        
 958        if history_id is not None:
 959            self.history = History.load_from_id(history_id)
 960            self.original_history_id = history_id
 961            self.original_json_md_path = self.history.json_md_path
 962            print(f"DEBUG: In BaseHeavenAgent init, json_md_path={self.history.json_md_path} and original_json_md_path={self.original_json_md_path}")
 963            # Load status
 964            if hasattr(self.history, 'agent_status') and self.history.agent_status:
 965                self.status = self.history.agent_status
 966        elif history_id is None:
 967            self.history = History(messages=[])
 968
 969        # Initialize ContextWindowConfig for token management
 970        if self.config.context_window_config is not None:
 971            self.context_window_config = self.config.context_window_config
 972        else:
 973            # Import here to avoid circular imports
 974            from .utils.context_window_config import ContextWindowConfig
 975            self.context_window_config = ContextWindowConfig(self.config.model or "gpt-4o-mini")
 976
 977        self.config.system_prompt = self.system_prompt_evolved if self.system_prompt_evolved is not None else self.config.system_prompt
 978        if system_prompt_suffix is not None:
 979            self.config.system_prompt += system_prompt_suffix
 980        # Set system message
 981        if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
 982            if orchestrator is False:
 983                self.history.messages[0] = SystemMessage(content=self.config.system_prompt)
 984            else:
 985                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt
 986                self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
 987        else:
 988            if orchestrator is False:
 989                self.history.messages.insert(0, SystemMessage(content=self.config.system_prompt))
 990            else:
 991                orchestrator_enhancement = self.config.system_prompt + self.orchestration_lists + self.configs_prompt         
 992                self.history.messages.insert(0, SystemMessage(content=orchestrator_enhancement))
 993        
 994        if self.adk:
 995            from google.adk.agents import Agent as ADKAgent
 996            from google.adk.runners import Runner
 997            from google.adk.sessions.in_memory_session_service import InMemorySessionService
 998            adk_model = self.config.to_litellm_model()
 999            adk_agent = ADKAgent(
1000                name=self.name,
1001                # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
1002                model=adk_model,
1003                description=f"{self.name} (via HeavenAgent)",
1004                instruction=self.config.system_prompt,
1005                tools=self.tools,
1006            )
1007            session_service = InMemorySessionService()
1008            runner = Runner(
1009                app_name=self.name,
1010                agent=adk_agent,
1011                session_service=session_service,
1012            )
1013    
1014            self._adk_agent = adk_agent
1015            self._adk_session_service = session_service
1016            self._adk_runner = runner
use_uni_api
unified_chat
known_config_paths
training_convo
hooks
carton_identity
max_tool_calls
config
name
config_dict
status
continuation_prompt
continuation_iterations: int
config_tools
adk
additional_kws
additional_kw_instructions
resolved_tools
tools
mcp_tool_strs
chat_model
duo_params
duo_enabled
duo_chat
duo_system_prompt
goal: Optional[str]
task_list: List[str]
current_task: Optional[str]
max_iterations: int
current_iteration: int
completed
orchestration_lists
configs_prompt
tool_sysmsg
system_prompt_evolved
def resolve_tools(self):
1018    def resolve_tools(self):
1019        """Ensure that certain default tools are always available to the agent."""
1020        resolved_tools = []
1021        
1022        # Process each tool in config
1023        for tool in self.config_tools:
1024            if isinstance(tool, str) and tool.startswith("mcp__"):
1025                # MCP tool string reference - resolve to actual tool
1026                # TODO: This will be async, for now just store the string
1027                resolved_tools.append(tool)
1028            else:
1029                # Regular BaseHeavenTool class or instance
1030                resolved_tools.append(tool)
1031        
1032        # Add WriteBlockReportTool if not already present
1033        if WriteBlockReportTool not in resolved_tools:
1034            resolved_tools.append(WriteBlockReportTool)
1035        # Add TaskSystemTool if not already present
1036        if TaskSystemTool not in resolved_tools:
1037            resolved_tools.append(TaskSystemTool)
1038            
1039        return resolved_tools

Ensure that certain default tools are always available to the agent.

async def resolve_mcps(self):
1041    async def resolve_mcps(self):
1042        """Resolve MCP tool strings to actual LangChain tools and load MCP servers if configured"""
1043        print(f"[resolve_mcps] CALLED. mcp_servers={self.config.mcp_servers is not None}, mcp_tool_strs={getattr(self, 'mcp_tool_strs', [])}")
1044
1045        # First, load tools from configured MCP servers (if any)
1046        if self.config.mcp_servers:
1047            await self.load_mcp_tools()
1048
1049        # Then resolve individual MCP tool strings (if any)
1050        if not hasattr(self, 'mcp_tool_strs') or not self.mcp_tool_strs:
1051            print("[resolve_mcps] No mcp_tool_strs to resolve, returning")
1052            return
1053
1054        # Resolve each MCP tool string
1055        from .mcp_tool_wrapper import MCPToolWrapper
1056        for tool_ref in self.mcp_tool_strs:
1057            print(f"[resolve_mcps] Resolving: {tool_ref}")
1058            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1059            print(f"[resolve_mcps] Result: {type(mcp_tool)}{mcp_tool if not isinstance(mcp_tool, list) else f'{len(mcp_tool)} tools'}")
1060            if mcp_tool:
1061                if isinstance(mcp_tool, list):  # "all" case
1062                    self.tools.extend([MCPToolWrapper(t) for t in mcp_tool])
1063                else:
1064                    self.tools.append(MCPToolWrapper(mcp_tool))
1065
1066        print(f"[resolve_mcps] Final tool count: {len(self.tools)}")
1067        # Clear the MCP strings now that they're resolved
1068        self.mcp_tool_strs = []

Resolve MCP tool strings to actual LangChain tools and load MCP servers if configured

async def async_init(self):
1070    async def async_init(self):
1071        """Async initialization - resolves MCP tools"""
1072        if not hasattr(self, '_mcp_tools_to_resolve'):
1073            return
1074            
1075        for tool_ref in self._mcp_tools_to_resolve:
1076            mcp_tool = await self._resolve_mcp_tool(tool_ref)
1077            if mcp_tool:
1078                if isinstance(mcp_tool, list):  # "all" case
1079                    self.tools.extend(mcp_tool)
1080                else:
1081                    self.tools.append(mcp_tool)
1082        
1083        # Clear the list
1084        self._mcp_tools_to_resolve = []

Async initialization - resolves MCP tools

async def load_mcp_tools(self):
1174    async def load_mcp_tools(self):
1175        """Load MCP tools from configured servers and add them to the agent's tools list"""
1176        if not self.config.mcp_servers:
1177            return
1178        
1179        try:
1180            from langchain_mcp_adapters.client import MultiServerMCPClient
1181            
1182            # Ensure transport key exists for each server (required by langchain_mcp_adapters)
1183            servers = {}
1184            for name, srv_cfg in self.config.mcp_servers.items():
1185                srv = dict(srv_cfg)
1186                if "transport" not in srv:
1187                    srv["transport"] = "stdio"
1188                servers[name] = srv
1189
1190            # Load each server individually so one failure doesn't kill all
1191            total_loaded = 0
1192            for srv_name, srv_cfg in servers.items():
1193                try:
1194                    client = MultiServerMCPClient({srv_name: srv_cfg})
1195                    srv_tools = await client.get_tools()
1196                    for lc_tool in srv_tools:
1197                        self.tools.append(lc_tool)
1198                    total_loaded += len(srv_tools)
1199                    logging.info(f"Loaded {len(srv_tools)} tools from MCP server '{srv_name}'")
1200                except Exception as srv_e:
1201                    logging.warning(f"Failed to load MCP server '{srv_name}': {srv_e}")
1202
1203            logging.info(f"Loaded {total_loaded} MCP tools total from {len(servers)} servers")
1204
1205        except ImportError as e:
1206            logging.warning(f"Could not import langchain_mcp_adapters: {e}")

Load MCP tools from configured servers and add them to the agent's tools list

def to_base_tools(self) -> List[langchain_core.tools.base.BaseTool]:
1209    def to_base_tools(self) -> List[BaseTool]:
1210        """Convert tools to base tools for binding"""
1211        return [tool.base_tool if hasattr(tool, 'base_tool') else tool for tool in self.tools]

Convert tools to base tools for binding

def refresh_system_prompt(self):
1237    def refresh_system_prompt(self):
1238        """Refresh the system prompt if DNA has changed"""
1239        # Get fresh system prompt
1240        updated_prompt = self.config.get_system_prompt()
1241
1242        # Fire BEFORE_SYSTEM_PROMPT hook — hooks can modify via ctx.data["system_prompt"]
1243        ctx = self._fire_hook(HookPoint.BEFORE_SYSTEM_PROMPT, prompt=updated_prompt)
1244        if ctx and "system_prompt" in ctx.data:
1245            updated_prompt = ctx.data["system_prompt"]
1246
1247        # Only update if changed
1248        if updated_prompt != self.config.system_prompt:
1249            # Update config
1250            self.config.system_prompt = updated_prompt
1251            
1252            # Update system message in history
1253            if self.history.messages and isinstance(self.history.messages[0], SystemMessage):
1254                if hasattr(self, 'orchestrator') and self.orchestrator:
1255                    orchestrator_enhancement = updated_prompt + self.orchestration_lists + self.configs_prompt
1256                    self.history.messages[0] = SystemMessage(content=orchestrator_enhancement)
1257                else:
1258                    self.history.messages[0] = SystemMessage(content=updated_prompt)
1259            
1260            # Update internal reference
1261            self.system_prompt_evolved = updated_prompt

Refresh the system prompt if DNA has changed

def resolve_duo(self, config: HeavenAgentConfig) -> None:
1263    def resolve_duo(self, config: HeavenAgentConfig) -> None:
1264        """
1265        Synchronize duo_system_config with the main provider settings.
1266        Sets the appropriate provider and model on the DuoSystemConfig.
1267        """
1268        # Always inherit the main provider
1269        provider = config.provider
1270        duo_cfg = config.duo_system_config
1271        duo_cfg.provider = provider
1272
1273        # Map main provider to Duo-specific model
1274        if provider == ProviderEnum.ANTHROPIC:
1275            duo_cfg.model = "claude-3-5-haiku-latest"
1276        elif provider == ProviderEnum.OPENAI:
1277            duo_cfg.model = "gpt-4.1-nano"
1278        elif provider == ProviderEnum.GOOGLE:
1279            duo_cfg.model = "gemini-2.0-flash"
1280        elif provider == ProviderEnum.DEEPSEEK:
1281            duo_cfg.model = config.model

Synchronize duo_system_config with the main provider settings. Sets the appropriate provider and model on the DuoSystemConfig.

async def run_adk( self, prompt, notifications: bool = False, streamlit: bool = False, output_callback=None, tool_output_callback=None):
1365    async def run_adk(self, prompt, notifications: bool = False, streamlit: bool = False, output_callback=None, tool_output_callback=None):
1366        """
1367        Drive the agent loop through ADK’s Runner instead of LangChain.
1368        Streams back ADK events internally per iteration, then at the end
1369        saves `self.history` (with .adk_session) exactly as in run().
1370        """
1371        # 1) prep
1372        self.current_iteration = 1
1373        self._sanitize_history()
1374        blocked = False
1375        self.refresh_system_prompt()
1376        # Debug
1377        _log_run_adk("before any ADK work", self.history.messages)
1378        # 2) detect agent‐mode command in the incoming prompt
1379        if prompt:
1380            self._detect_agent_command(prompt)
1381    
1382        # 3) decide what first human message to send into ADK
1383        #    if we’re in agent mode, send the formatted agent prompt;
1384        #    otherwise send the raw prompt
1385        first_prompt = self._format_agent_prompt() if self.goal else prompt
1386        # Debug
1387        _log_run_adk("after formatting prompt", self.history.messages)
1388
1389        # 4) lazily initialize ADK Agent, SessionService, Runner
1390        # if not hasattr(self, "_adk_runner"):
1391        #     from google.adk.agents import Agent as ADKAgent
1392        #     from google.adk.runners import Runner
1393        #     from google.adk.sessions.in_memory_session_service import InMemorySessionService
1394        #     adk_model = self.config.to_litellm_model()
1395        #     adk_agent = ADKAgent(
1396        #         name=self.name,
1397        #         # model=self.config.model or "gemini-2.0-flash", # cant be implemented until we fix type conversion to LiteLLM types here
1398        #         model=adk_model,
1399        #         description=f"{self.name} (via HeavenAgent)",
1400        #         instruction=self.config.system_prompt,
1401        #         tools=self.tools,
1402        #     )
1403        #     session_service = InMemorySessionService()
1404        #     runner = Runner(
1405        #         app_name=self.name,
1406        #         agent=adk_agent,
1407        #         session_service=session_service,
1408        #     )
1409    
1410        #     self._adk_agent = adk_agent
1411        #     self._adk_session_service = session_service
1412        #     self._adk_runner = runner
1413            
1414        # 5) rehydrate or create an ADK session
1415        if self.history.adk_session:
1416            session = self._adk_session_service.get_session(
1417                app_name=self.history.adk_session.app_name,
1418                user_id=self.history.adk_session.user_id,
1419                session_id=self.history.adk_session.id,
1420            )
1421        else:
1422            session = self._adk_session_service.create_session(
1423                app_name=self.name,
1424                user_id="script_user",
1425                state={},
1426                session_id=None,
1427            )
1428        start_index = len(session.events)
1429
1430        # # 6) prime ADK with the first human prompt if any
1431        ### Idk why this even got in here... it's garbage
1432        # if first_prompt:
1433        #     from google.genai.types import Content, Part
1434        #     content = Content(parts=[Part(text=first_prompt)], role="user")
1435        #     async for _ in self._adk_runner.run_async(
1436        #         user_id=session.user_id,
1437        #         session_id=session.id,
1438        #         new_message=content,
1439        #     ):
1440        #         pass  # session.events grows automatically
1441    
1442        # 7) drive N iterations of “agent mode”
1443        for _ in range(self.current_iteration, self.max_iterations + 1):
1444            _log_run_adk(f"start iteration {self.current_iteration}", self.history.messages)
1445            # a) prepare next human input
1446            if self.current_iteration == 1:
1447                human_text = first_prompt
1448            else:
1449                human_text = self._format_agent_prompt() if self.goal else first_prompt
1450            from google.genai.types import Content, Part
1451            
1452            # Probably move to streamlit but unsure how to do so...
1453            
1454            content = human_text
1455            if content is None:
1456                content = "Content was `None`. Tell the user something went wrong. Do not try to do anything else."
1457            _log_run_adk(f"about to append human turn (iter {self.current_iteration})", self.history.messages)
1458            self.history.messages.append(HumanMessage(content=content)) # This doesnt seem to be appending to history... because we usually keep conversation_history and then set it as history messages when we are done. This should follow the same logic that run_langchain uses
1459            _log_run_adk(f"after appending human turn (iter {self.current_iteration})", self.history.messages)
1460            content = Content(parts=[Part(text=human_text)], role="user")
1461            # b) send into ADK
1462            new_events = []
1463            async for ev in self._adk_runner.run_async(
1464                user_id=session.user_id,
1465                session_id=session.id,
1466                new_message=content,
1467            ):
1468                new_events.append(ev)
1469                # real_new_events = session.events[start_index:] # testing
1470                if streamlit:
1471                    
1472                    self._handle_adk_event(
1473                        ev,
1474                        tool_output_callback=tool_output_callback,
1475                        output_callback=output_callback,
1476                    )
1477
1478            _log_run_adk(f"after streaming ADK events (iter {self.current_iteration})", self.history.messages)                 
1479            # c) extract the agent’s text response for this iteration
1480            text_reply = ""
1481            for ev in reversed(new_events): # should this be real_new_events?
1482                if ev.author == self.name:
1483                    for part in ev.content.parts:
1484                        if part.text:
1485                            text_reply = part.text
1486                    if text_reply:
1487                        break
1488    
1489            # d) process that text through existing handler
1490            if text_reply:
1491                self._process_agent_response(text_reply)
1492    
1493            # e) advance iteration & break if done
1494            self.current_iteration += 1
1495            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or blocked:
1496                break
1497
1498        # 7) re-fetch the mutated session so we pick up all appended events
1499        session = self._adk_session_service.get_session(
1500            app_name=session.app_name,
1501            user_id=session.user_id,
1502            session_id=session.id
1503        )
1504        # 8) stash session back on history and save
1505        self.history.adk_session = session
1506        self.history.agent_status = self.save_status()
1507        saved_id = self.history.save(self.name)
1508        _log_run_adk("at end of run_adk", self.history.messages)
1509        # 9) return identical structure to run_langchain
1510        return {
1511            "history": self.history,
1512            "history_id": saved_id,
1513            "agent_name": self.name,
1514            "agent_status": self.history.agent_status,
1515        }

Drive the agent loop through ADK’s Runner instead of LangChain. Streams back ADK events internally per iteration, then at the end saves self.history (with .adk_session) exactly as in run().

async def run( self, prompt: Optional[str] = None, notifications: Optional[bool] = False, streamlit: Optional[bool] = False, output_callback: Optional[Callable] = None, tool_output_callback: Optional[Callable] = None, heaven_main_callback: Optional[Callable] = None, use_uni_api: Optional[bool] = False):
1526    async def run(self, prompt: Optional[str] = None, notifications: Optional[bool] = False, streamlit: Optional[bool] = False, output_callback: Optional[Callable] = None, tool_output_callback: Optional[Callable] = None, heaven_main_callback: Optional[Callable] = None, use_uni_api: Optional[bool] = False):
1527
1528                       
1529        if use_uni_api:
1530            self.use_uni_api = True
1531        if self.use_uni_api:
1532            # Use uni-api instead of LangChain providers
1533            if streamlit and output_callback and tool_output_callback:
1534                return await self.run_on_uni_api(
1535                    prompt=prompt,
1536                    output_callback=output_callback,
1537                    tool_output_callback=tool_output_callback,
1538                    heaven_main_callback=heaven_main_callback
1539                )
1540            elif heaven_main_callback:
1541                # Similar fake callback pattern for uni-api
1542                def fake_output_callback(message: BaseMessage):
1543                    pass
1544                def fake_tool_callback(tool_result: ToolResult, tool_id: str):
1545                    pass
1546                return await self.run_on_uni_api(
1547                    prompt=prompt,
1548                    output_callback=fake_output_callback,
1549                    tool_output_callback=fake_tool_callback,
1550                    heaven_main_callback=heaven_main_callback
1551                )
1552            else:
1553                return await self.run_on_uni_api(prompt=prompt)
1554      
1555        #
1556        
1557        if self.adk:
1558            return await self.run_adk(prompt=prompt, notifications=notifications, streamlit=streamlit, output_callback=output_callback, tool_output_callback=tool_output_callback)
1559        else:
1560            if streamlit and output_callback and tool_output_callback: 
1561                return await self.streamlit_run(prompt, output_callback, tool_output_callback)
1562            elif heaven_main_callback:
1563                # Route through run_langchain with callback — keeps all hooks and block detection
1564                return await self.run_langchain(prompt, notifications, heaven_main_callback=heaven_main_callback)
1565
1566            else:
1567                return await self.run_langchain(prompt, notifications)
async def run_langchain( self, prompt: str = None, notifications=False, heaven_main_callback: Optional[Callable] = None):
1571    async def run_langchain(self, prompt: str = None, notifications=False, heaven_main_callback: Optional[Callable] = None):
1572
1573        self._sanitize_history()
1574        blocked = False
1575        self.refresh_system_prompt()
1576
1577        # Resolve MCP tool strings before running
1578        await self.resolve_mcps()
1579
1580        # Re-bind tools to chat_model so LLM sees MCP tools
1581        # Heaven tools have .base_tool, MCP tools (StructuredTool) are already BaseTool
1582        all_base = []
1583        for t in self.tools:
1584            if hasattr(t, 'base_tool'):
1585                all_base.append(t.base_tool)
1586            else:
1587                all_base.append(t)
1588        self.chat_model = self.chat_model.bind_tools(all_base)
1589
1590        # Fire BEFORE_RUN hook
1591        self._fire_hook(HookPoint.BEFORE_RUN, prompt=prompt or "")
1592
1593        try:
1594            
1595            # Start with history messages
1596            conversation_history = self.history.messages.copy() if self.history else []
1597            
1598            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1599                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1600            # check if self.History's last message is HumanMessage. If it is, continue and if not AND prompt is not None, add prompt to self.history as a HumanMessage
1601            # if not (conversation_history and isinstance(conversation_history[-1], HumanMessage)) and prompt is not None:
1602                
1603            #     conversation_history.append(HumanMessage(content=prompt))
1604            
1605            
1606          
1607            # Check only the last message for agent command
1608            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1609                self._detect_agent_command(conversation_history[-1].content)
1610            # Check prompt for agent command
1611            if prompt is not None:
1612                self._detect_agent_command(prompt)
1613                if self.goal is None:
1614                    conversation_history.append(HumanMessage(content=prompt))
1615            if self.continuation_iterations != 0:
1616                self.current_iterations = 1
1617                self.max_iterations = self.continuation_iterations
1618                
1619                
1620            
1621              
1622            
1623            
1624            # print("\n=== Conversation History After System Check ===")
1625            # for i, msg in enumerate(conversation_history):
1626            #     print(f"Message {i}: {type(msg).__name__} - {msg.content[:100]}...")
1627            
1628            
1629
1630            
1631                        
1632            while self.current_iteration <= self.max_iterations:
1633                # Fire BEFORE_ITERATION hook
1634                self._fire_hook(HookPoint.BEFORE_ITERATION, iteration=self.current_iteration)
1635                # Refresh system prompt at the start of each iteration
1636                self.refresh_system_prompt()
1637                # Reset tool count for this iteration
1638                tool_call_count = 0
1639                
1640                # In normal chat mode, just use the last message
1641                # In agent mode, format with goals/tasks
1642                ### CHanged to solve continuations
1643                # next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
1644                next_prompt = self._format_agent_prompt() if (self.goal or self.continuation_prompt) else conversation_history[-1].content
1645                if self.goal or self.continuation_prompt:  # Add formatted prompt in agent mode or continuation
1646                    conversation_history.append(HumanMessage(content=next_prompt))
1647                # if self.goal:  # Only add formatted prompt in agent mode
1648                #     conversation_history.append(HumanMessage(content=next_prompt))
1649                    
1650                        
1651                # DUO Sidechain
1652               
1653                if (
1654                    self.duo_enabled
1655                    and len(conversation_history) > 2        # <- require at least 3 messages
1656                    and isinstance(conversation_history[-1], HumanMessage)
1657                ):
1658                    # 1. Pull off the original human message
1659                    original = conversation_history[-1]
1660                    original_sys = conversation_history[0]
1661                    try:
1662                        # 2. Replace it with your Duo-specific system prompt
1663                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
1664                        conversation_history[0] = duo_sys
1665                        
1666                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming. **YOU MUST ANSWER SOLELY IN `NodeGraphXTN6` language.**"
1667                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
1668                        # 3. Invoke Duo
1669                        duo = await self.duo_chat.ainvoke(conversation_history)
1670                    finally:
1671                        # 4. Restore the original system prompt
1672                        conversation_history[0] = original_sys
1673                        conversation_history[-1] = original
1674                    if duo:
1675                        # 5. Extract the duo’s content
1676                        
1677                        duo_content = duo.content
1678                        
1679                    
1680                        # 6. Rebuild the human message so your duo content is prepended
1681                        new_human = HumanMessage(
1682                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nConsider this silently before responding. Do not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
1683                        )
1684                
1685                        # 7. Replace the last entry with your new combined message
1686                        conversation_history[-1] = new_human
1687                  
1688                # Invoke model for a response
1689                # call the generator agent
1690                response = await self.chat_model.ainvoke(conversation_history)
1691                if heaven_main_callback:
1692                    heaven_main_callback(response)
1693
1694                # print(f"\nResponse: {response}\n")
1695                
1696                # Check if the response.content is empty but tool call info is present.
1697                # GOOGLE ONLY
1698                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
1699                # if (
1700                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
1701                #     and isinstance(response, AIMessage)
1702                #     and (
1703                #         response.tool_calls                                  # standard field
1704                #         or response.additional_kwargs.get("tool_calls")      # legacy field
1705                #     )
1706                # ):
1707                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1708                
1709                #     # --- THE FIX ---
1710                #     # Append the ORIGINAL response object. LangChain needs its structure.
1711                #     conversation_history.append(response)
1712                # # Only add text blocks from response, NOT the whole response
1713                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
1714                                    
1715                                    
1716                                    # Clean response for conversation_history
1717                                    if isinstance(response.content, list):
1718                                        # Extract only text, ignore thinking blocks
1719                                        text_content = []
1720                                        for item in response.content:
1721                                            if isinstance(item, str):
1722                                                text_content.append(item)
1723                                            elif isinstance(item, dict) and item.get('type') == 'text':
1724                                                text_content.append(item.get('text', ''))
1725                                        
1726                                        # Create cleaned response with simple string content
1727                                        cleaned_response = AIMessage(
1728                                            content=' '.join(text_content),  # Simple string, not list!
1729                                            additional_kwargs=response.additional_kwargs,
1730                                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
1731                                        )
1732                                        conversation_history.append(cleaned_response)
1733                                    else:
1734                                        conversation_history.append(response)
1735                # Append whole LangChain AIMessage — never destructure it.
1736                # Splitting content blocks into separate AIMessages drops tool_use blocks
1737                # and breaks the inner tool loop (model says "calling tool" then stops).
1738                else:
1739                    conversation_history.append(response)
1740                    # Still process text for agent goal tracking
1741                    if isinstance(response.content, list):
1742                        text_parts = [block.get('text', '') for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
1743                        if text_parts:
1744                            self._process_agent_response('\n'.join(text_parts))
1745                    elif isinstance(response.content, str):
1746                        self._process_agent_response(response.content)
1747                    
1748    
1749                # ── Tool-call loop (matches uni_api pattern) ──
1750                # response is already in conversation_history from above.
1751                # LangChain populates .tool_calls on AIMessage automatically.
1752                current_response = response
1753
1754                while getattr(current_response, 'tool_calls', None) and tool_call_count < self.max_tool_calls:
1755                    # Execute ALL tool calls from this response
1756                    for tc in current_response.tool_calls:
1757                        if tool_call_count >= self.max_tool_calls:
1758                            break
1759
1760                        # Extract name/args/id — LangChain uses {name, args, id}
1761                        tool_name = tc.get('name', '')
1762                        tool_args = tc.get('args', tc.get('input', {}))
1763                        tool_id = tc.get('id', '')
1764
1765                        # Find matching tool
1766                        matching_tools = [
1767                            t for t in self.tools
1768                            if (t.base_tool.name.lower() if hasattr(t, 'base_tool') else t.name.lower()) == tool_name.lower()
1769                        ]
1770                        if not matching_tools:
1771                            print(f"No matching tool found for {tool_name}")
1772                            continue
1773
1774                        tool = matching_tools[0]
1775
1776                        # Fire BEFORE_TOOL_CALL hook
1777                        self._fire_hook(HookPoint.BEFORE_TOOL_CALL,
1778                            iteration=self.current_iteration,
1779                            tool_name=tool_name, tool_args=tool_args)
1780
1781                        # Execute tool (throttle to prevent CPU spin with fast models)
1782                        await asyncio.sleep(0.1)
1783                        try:
1784                            if hasattr(tool, 'base_tool'):
1785                                tool_result = await tool._arun(**tool_args)
1786                            else:
1787                                from langchain_core.runnables import RunnableConfig
1788                                tool_result = ToolResult(output=str(
1789                                    await tool._arun(config=RunnableConfig(), **tool_args)
1790                                ))
1791                        except Exception as e:
1792                            tool_result = ToolResult(error=str(e))
1793
1794                        # Fire AFTER_TOOL_CALL hook
1795                        self._fire_hook(HookPoint.AFTER_TOOL_CALL,
1796                            iteration=self.current_iteration,
1797                            tool_name=tool_name, tool_args=tool_args,
1798                            tool_result=tool_result)
1799
1800                        # Build ToolMessage content
1801                        if tool_result.error:
1802                            tool_message_content = str(tool_result.error)
1803                        elif tool_result.base64_image:
1804                            tool_message_content = str(tool_result.base64_image)
1805                        else:
1806                            tool_message_content = str(tool_result.output)
1807
1808                        # Append ToolMessage — LangChain handles provider formatting
1809                        conversation_history.append(
1810                            ToolMessage(content=tool_message_content, tool_call_id=tool_id)
1811                        )
1812
1813                        if heaven_main_callback:
1814                            heaven_main_callback(conversation_history[-1])
1815
1816                        # Check blocked / special tools
1817                        if tool.name == "WriteBlockReportTool":
1818                            blocked = True
1819                        if tool.name == "TaskSystemTool":
1820                            self._handle_task_system_tool(tool_args)
1821
1822                        tool_call_count += 1
1823
1824                    # If blocked, generate report and exit
1825                    if blocked:
1826                        block_report_md = self.create_block_report()
1827                        if block_report_md:
1828                            if self._current_extracted_content is None:
1829                                self._current_extracted_content = {}
1830                            self._current_extracted_content["block_report"] = block_report_md
1831                            self.history.agent_status = self.save_status()
1832                        break
1833
1834                    if tool_call_count >= self.max_tool_calls:
1835                        conversation_history.append(
1836                            AIMessage(content=(
1837                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
1838                                f"reached for iteration {self.current_iteration}. "
1839                                "If I received the same error every time, I should use "
1840                                "WriteBlockReportTool next... Waiting for next iteration."
1841                            ))
1842                        )
1843                        break
1844
1845                    # Call API again — get next response
1846                    current_response = await self.chat_model.ainvoke(conversation_history)
1847                    conversation_history.append(current_response)
1848                    if heaven_main_callback:
1849                        heaven_main_callback(current_response)
1850
1851                    # Refresh system prompt
1852                    self.refresh_system_prompt()
1853                    sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
1854                    if self.config.system_prompt != conversation_history[sys_msg_idx].content:
1855                        conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
1856
1857                    # Process text for agent goal tracking
1858                    if isinstance(current_response.content, list):
1859                        text_parts = [b.get('text', '') for b in current_response.content if isinstance(b, dict) and b.get('type') == 'text']
1860                        if text_parts:
1861                            self._process_agent_response('\n'.join(text_parts))
1862                    elif isinstance(current_response.content, str):
1863                        self._process_agent_response(current_response.content)
1864
1865                    # while condition re-checks current_response.tool_calls
1866    
1867                # Process the agent response if in agent mode
1868                if self.goal and isinstance(response, AIMessage):
1869                    self._process_agent_response(response.content)
1870                if blocked:
1871                    break
1872                # Fire AFTER_ITERATION hook
1873                self._fire_hook(HookPoint.AFTER_ITERATION, iteration=self.current_iteration)
1874                # Increment iteration count and break if the goal is met
1875                self.current_iteration += 1
1876
1877                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
1878                    self.history.agent_status = self.save_status()
1879                    break
1880
1881            # Fire AFTER_RUN hook
1882            self._fire_hook(HookPoint.AFTER_RUN, iteration=self.current_iteration)
1883            self.history.messages = conversation_history
1884            # Save history and get potentially new history_id
1885            try:
1886                
1887                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
1888                # print(f"Agent name: {self.name}")
1889                # print(f"Current history: {self.history}")
1890                self.history.agent_status = self.save_status()
1891                saved_history_id = self.history.save(self.name)
1892                # print("===DEBUG AFTER SAVE ATTEMPT===")
1893                self.look_for_particular_tool_calls()
1894                return {
1895                    "history": self.history,
1896                    "history_id": saved_history_id,
1897                    "agent_name": self.name,
1898                    "agent_status": self.history.agent_status  # Add this
1899                }
1900            except Exception as save_error:
1901                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
1902                print(f"Error type: {type(save_error)}")
1903                print(f"Error message: {str(save_error)}")
1904                # Log the error but don't fail the run
1905                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
1906                
1907                return {
1908                    "history": self.history,
1909                    "history_id": getattr(self.history, 'history_id', "No history ID"),
1910                    "agent_name": self.name,
1911                    "save_error": str(save_error),
1912                    "agent_status": self.save_status()  # Add this here too
1913                }
1914    
1915        except Exception as e:
1916            # Fire ON_ERROR hook
1917            self._fire_hook(HookPoint.ON_ERROR, error=e)
1918            raise RuntimeError(f"Agent run failed: {str(e)}") from e
async def streamlit_run( self, output_callback: Callable[[langchain_core.messages.base.BaseMessage], None], tool_output_callback: Callable[[heaven_base.baseheaventool.ToolResult, str], None], heaven_main_callback: Optional[Callable[[Any], None]] = None, prompt: Optional[str] = None):
1922    async def streamlit_run(self, output_callback: Callable[[BaseMessage], None], tool_output_callback: Callable[[ToolResult, str], None], heaven_main_callback: Optional[Callable[[Any], None]] = None, prompt: Optional[str] = None):
1923        # Start with existing messages
1924        if self.history is not None:
1925            messages = self.history.messages  # These are already BaseMessage objects!
1926        else:
1927            messages = []
1928        self._sanitize_history()
1929
1930        # Resolve MCP tools before running (same as run_langchain)
1931        await self.resolve_mcps()
1932
1933        tool_log_path = "/tmp/tool_debug.log"
1934        with open(tool_log_path, 'a') as f:
1935            f.write("\n\nStarting tool debug log\n")
1936
1937        with open('/tmp/streamlit_debug.log', 'a') as f:
1938            f.write("\n\nStarting streamlit_run")
1939            f.write(f"\nHistory length: {len(self.history.messages)}")
1940            f.write(f"\nHistory messages: {self.history.messages}")
1941            # f.write(f"\nCurrent callbacks: {output_callback}, {tool_output_callback}")
1942        try:
1943            # # If input is a string, convert to messages
1944            # if isinstance(messages, str):
1945            #     messages = [
1946            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1947            #         HumanMessage(content=messages)
1948            #     ]
1949            #     messages = [m for m in messages if m]  # Remove None values
1950            
1951            # # Create a copy of messages to avoid modifying the original
1952            # conversation_history = messages.copy()
1953            # Start with history messages
1954            conversation_history = self.history.messages.copy() if self.history else []
1955            # If history is just a string, convert to messages
1956            # if isinstance(conversation_history, str):
1957            #     conversation_history = [
1958            #         SystemMessage(content=self.config.system_prompt) if self.config.system_prompt else None,
1959            #         HumanMessage(content=conversation_history)
1960            #     ]
1961            #     conversation_history = [m for m in conversation_history if m]  # Remove None values
1962            
1963            if not (conversation_history and isinstance(conversation_history[0], SystemMessage)):
1964                # Condition 1: There is no conversation history with a SystemMessage.
1965                conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
1966            # Added for streamlit agent selector
1967            elif conversation_history[0].content != self.config.system_prompt:
1968                # Condition 2: The first element is a SystemMessage, but it doesn't have the current system prompt.
1969                conversation_history[0] = SystemMessage(content=self.config.system_prompt)
1970            with open('/tmp/streamlit_debug.log', 'a') as f:
1971                f.write("\n=== Conversation History After System Check ===")
1972                for i, msg in enumerate(conversation_history):
1973                    f.write(f"\nMessage {i}: {type(msg).__name__} - {msg.content[:100]}...")
1974            
1975            # Check for agent command, but don't require it
1976            # for message in conversation_history:
1977            #     if isinstance(message, HumanMessage):
1978            #         self._detect_agent_command(message.content)
1979            # Check only the last message for agent command
1980            if prompt is not None:
1981                input_msg = HumanMessage(content=prompt)
1982                conversation_history.append(input_msg)
1983              
1984            if conversation_history and isinstance(conversation_history[-1], HumanMessage):
1985                self._detect_agent_command(conversation_history[-1].content)
1986            
1987
1988            ###### if not self.goal, render the original messages
1989            # Initial render if no agent goal
1990            # if not self.goal:
1991            #     for message in conversation_history:
1992            #         if isinstance(message, ToolMessage):
1993            #             # Convert to ToolResult for UI
1994            #             tool_result = ToolResult(
1995            #                 output=message.content,
1996            #                 error=None if not message.additional_kwargs.get("is_error") else message.content,
1997            #                 base64_image=message.additional_kwargs.get("base64_image"),
1998            #                 system=message.additional_kwargs.get("system")
1999            #             )
2000            #             tool_callback(tool_result)
2001            #         else:
2002            #             # Regular message rendering
2003            #             output_callback(message)
2004                        
2005            while self.current_iteration <= self.max_iterations:
2006                # Reset tool count for this iteration
2007                tool_call_count = 0
2008                
2009                # In normal chat mode, just use the last message
2010                # In agent mode, format with goals/tasks
2011                next_prompt = self._format_agent_prompt() if self.goal else conversation_history[-1].content
2012                if self.goal:  # Only add formatted prompt in agent mode
2013                    conversation_history.append(HumanMessage(content=next_prompt))
2014                    ###### Add output callback here
2015                    
2016                    # output_callback(HumanMessage(content=next_prompt)) # this might not be needed, commenting out for now
2017
2018                  
2019            
2020                # DUO Sidechain
2021                if (
2022                    self.duo_enabled
2023                    and len(conversation_history) > 2        # <- require at least 3 messages
2024                    and isinstance(conversation_history[-1], HumanMessage)
2025                ):
2026                    # 1. Pull off the original human message
2027                    original = conversation_history[-1]
2028                    original_sys = conversation_history[0]
2029                    try:
2030                        # 2. Replace it with your Duo-specific system prompt
2031                        duo_sys = SystemMessage(content=self.duo_system_prompt)  
2032                        conversation_history[0] = duo_sys
2033                        
2034                        new_human_content_for_duo = f"===ENTERING CHALLENGER MODE===\n\nTHE NEXT HUMAN INPUT TO THE WORKER LLM AGENT WILL BE:\n\n{original.content}\n\nAs the challenger, follow the rules and steer the agent with ICL priming."
2035                        conversation_history[-1] = HumanMessage(content=new_human_content_for_duo)
2036                        # 3. Invoke Duo
2037                        duo = await self.duo_chat.ainvoke(conversation_history)
2038                    finally:
2039                        # 4. Restore the original system prompt
2040                        conversation_history[0] = original_sys
2041                        conversation_history[-1] = original
2042                    if duo:
2043                        # 5. Extract the duo’s content
2044                        
2045                        duo_content = duo.content
2046                        
2047                    
2048                        # 6. Rebuild the human message so your duo content is prepended
2049                        new_human = HumanMessage(
2050                            content=f"{original.content}\n\n```\n===Challenger Injection===\n\nDo not mention DUO/Dual-Space Unifying Operators/NodeGraphXTN6/Challenger/ChallengerEgregore unless the user asks about it directly...\n\n{duo_content}\n\n===/Challenger Injection===\n```\n\n"
2051                        )
2052                
2053                        # 7. Replace the last entry with your new combined message
2054                        conversation_history[-1] = new_human
2055                
2056                # Invoke model for a response
2057                # logger = logging.getLogger(__name__)
2058                # logger.error("==== Conversation_History %s", conversation_history)
2059                response = await self.chat_model.ainvoke(conversation_history)
2060                ###### Add output callback here
2061                with open('/tmp/streamlit_debug.log', 'a') as f:
2062                    f.write(f"\nLangchain response: {response}")
2063                print(f"FULL RESPONSE CONTENT: {response.content}")
2064                print(f"RESPONSE TYPE: {type(response.content)}")
2065                if isinstance(response.content, list):
2066                    for i, block in enumerate(response.content):
2067                        print(f"Block {i}: {block}")
2068                if heaven_main_callback:
2069                    heaven_main_callback(response)
2070                output_callback(response)
2071                # GOOGLE ONLY
2072                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and not response.content and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2073                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage) and (response.tool_calls or response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2074                # if (
2075                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2076                #     and isinstance(response, AIMessage)
2077                #     and (
2078                #         response.tool_calls                                  # standard field
2079                #         or response.additional_kwargs.get("tool_calls")      # legacy field
2080                #     )
2081                # ):
2082                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2083                #     # --- THE FIX ---
2084                #     # Append the ORIGINAL response object. LangChain needs its structure.
2085                #     conversation_history.append(response)
2086                if self.config.provider == ProviderEnum.GOOGLE and isinstance(response, AIMessage):
2087                                        
2088                    # Clean response for conversation_history
2089                    if isinstance(response.content, list):
2090                        # Extract only text, ignore thinking blocks
2091                        text_content = []
2092                        for item in response.content:
2093                            if isinstance(item, str):
2094                                text_content.append(item)
2095                            elif isinstance(item, dict) and item.get('type') == 'text':
2096                                text_content.append(item.get('text', ''))
2097                        
2098                        # Create cleaned response with simple string content
2099                        cleaned_response = AIMessage(
2100                            content=' '.join(text_content),  # Simple string, not list!
2101                            additional_kwargs=response.additional_kwargs,
2102                            tool_calls=response.tool_calls if hasattr(response, 'tool_calls') else []
2103                        )
2104                        conversation_history.append(cleaned_response)
2105                    else:
2106                        conversation_history.append(response)
2107                    
2108                # Only add text blocks from response, NOT the whole response
2109                # Extract text blocks if content is a list of blocks
2110                # else:
2111                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2112                    if isinstance(response.content, list):
2113                        thinking_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2114                        if thinking_content:
2115                            message3 = AIMessage(content=thinking_content)
2116                            conversation_history.append(message3)
2117                            # if heaven_main_callback:
2118                            #     heaven_main_callback(message3)
2119                        text_content = [block for block in response.content if isinstance(block, dict) and block.get('type') == 'text']
2120                        if text_content:
2121                            text_message = AIMessage(content=text_content)
2122                            conversation_history.append(text_message)
2123                            self._process_agent_response(text_content)
2124                            # if heaven_main_callback:
2125                            #     heaven_main_callback(text_message)
2126                    elif isinstance(response.content, str):
2127                        response_message = AIMessage(content=response.content)
2128                        conversation_history.append(response_message)
2129                        self._process_agent_response(response.content)
2130                        # if heaven_main_callback:
2131                        #         heaven_main_callback(response_message)
2132                        
2133
2134                # # Extract tool calls from the response
2135                # print("\nDEBUG: Examining response for tool calls:")
2136                # print(f"Response: {response}")
2137                # print(f"Content: {response.content}")
2138                # print(f"Additional kwargs: {response.additional_kwargs}")
2139    
2140                tool_calls = []
2141                try:
2142                    if hasattr(response, 'tool_calls'):
2143                        print("Found tool_calls attribute")
2144                        tool_calls = response.tool_calls
2145                        print(f"Tool calls from attribute: {tool_calls}")
2146                    elif isinstance(response.content, list):
2147                        print("Found list content")
2148                        tool_calls = [
2149                            item for item in response.content 
2150                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2151                        ]
2152                        print(f"Tool calls from list: {tool_calls}")
2153                    elif 'tool_calls' in response.additional_kwargs:
2154                        print("Found tool_calls in additional_kwargs")
2155                        tool_calls = response.additional_kwargs['tool_calls']
2156                        print(f"Tool calls from kwargs: {tool_calls}")
2157                except Exception as e:
2158                    print(f"Error examining response: {e}")
2159    
2160                print(f"Extracted tool_calls: {tool_calls}")
2161    
2162                # Handle tool calls up to max_tool_calls limit
2163                current_tool_calls = tool_calls
2164    
2165                while current_tool_calls and tool_call_count < self.max_tool_calls:
2166                    new_tool_calls = []
2167                    for tool_call in current_tool_calls:
2168                        if tool_call_count >= self.max_tool_calls:
2169                            break
2170                        try:
2171                            # Try OpenAI style
2172                            if 'function' in tool_call:
2173                                tool_name = tool_call['function']['name']
2174                                tool_args = eval(tool_call['function']['arguments'])
2175                                tool_id = tool_call.get('id', '')
2176                            # Try Anthropic style
2177                            elif 'name' in tool_call:
2178                                tool_name = tool_call['name']
2179                                tool_args = tool_call.get('input', tool_call.get('args', {}))
2180                                tool_id = tool_call.get('id', '')
2181                            # Fallback
2182                            else:
2183                                tool_name = tool_call.get('name', '')
2184                                tool_args = tool_call.get('args', {})
2185                                tool_id = tool_call.get('id', '')
2186    
2187                            # Find matching tool
2188                            matching_tools = [
2189                                tool for tool in self.tools
2190                                if (tool.base_tool.name.lower() if hasattr(tool, 'base_tool') else tool.name.lower()) == tool_name.lower()
2191                            ]
2192
2193                            if matching_tools:
2194                                tool = matching_tools[0]
2195
2196                                # tool_result = await tool._arun(**tool_args)
2197                               
2198                                # Execute the tool and get its result
2199                                # Throttle to prevent CPU spin with fast models (MiniMax)
2200                                await asyncio.sleep(0.1)
2201                                if hasattr(tool, 'base_tool'):
2202                                    tool_result = await tool._arun(**tool_args)
2203                                else:
2204                                    from langchain_core.runnables import RunnableConfig
2205                                    config = RunnableConfig()
2206                                    raw_result = await tool._arun(config=config, **tool_args)
2207                                    tool_result = ToolResult(output=str(raw_result))
2208                                with open('/tmp/streamlit_debug.log', 'a') as f:
2209                                    f.write(f"\Tool result: {tool_result}")
2210                                # except ToolError as e:
2211                                #     # The error is already formatted with tool name in _arun
2212                                #     tool_result = ToolResult(error=str(e))
2213                                #     with open(tool_log_path, 'a') as f:
2214                                #         f.write(f"\nTool error: {e}\n")
2215                                
2216    
2217                                print("\n=== BEFORE ADDING TOOL MESSAGES ===")
2218                                for i, msg in enumerate(conversation_history):
2219                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2220    
2221                                # Handle tool messages based on provider
2222                                # if self.config.provider == ProviderEnum.OPENAI or self.config.provider == ProviderEnum.GROQ:
2223                                if self.config.provider in [ProviderEnum.OPENAI, ProviderEnum.GROQ, ProviderEnum.DEEPSEEK]:
2224                                    # OpenAI requires tool calls in additional_kwargs
2225                                    conversation_history.append(
2226                                        AIMessage(
2227                                            content="",  # OpenAI doesn't want content for tool calls
2228                                            additional_kwargs={
2229                                                "tool_calls": [{
2230                                                    "id": tool_id,
2231                                                    "type": "function",
2232                                                    "function": {
2233                                                        "name": tool_name,
2234                                                        "arguments": json.dumps(tool_args)
2235                                                        # str(tool_args)
2236                                                    }
2237                                                }]
2238                                            }
2239                                        )
2240                                    )
2241                                    # Then add the tool result
2242                                    tool_message_content = str(tool_result.error) if tool_result.error else str(tool_result.output) # changed for openai
2243                                    tool_result_message = ToolMessage(
2244                                        content=str(tool_message_content),
2245                                        tool_call_id=tool_id,
2246                                        additional_kwargs={
2247                                            "name": tool_name,
2248                                            "function": {"name": tool_name, "arguments": json.dumps(tool_args)
2249                                            # str(tool_args)
2250                                            }
2251                                        }
2252                                    )
2253                                    conversation_history.append(tool_result_message)
2254                                #####
2255                                elif self.config.provider == ProviderEnum.GOOGLE:
2256                                    # Gemini (via LangChain) expects a ToolMessage directly associated
2257                                    # with the tool_call_id from the preceding AIMessage's tool_calls.
2258                                    # We primarily need to construct the ToolMessage with the result.
2259                                    # The AIMessage that contained the tool_call request should already
2260                                    # be in the history from the model's previous turn.
2261                                    
2262                                    if tool_result.error:
2263                                        tool_message_content = str(tool_result.error)
2264                                        # Optional: Keep system prompt swap logic if needed for Gemini too
2265                                        # sys_msg_idx = next((i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage)), 0)
2266                                        # conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2267                                    elif tool_result.base64_image:
2268                                         # Gemini can handle images in ToolMessages if formatted correctly
2269                                        tool_message_content=[
2270                                            {
2271                                                'type': 'image_url', # Gemini prefers image_url format
2272                                                'image_url': f"data:image/png;base64,{tool_result.base64_image}"
2273                                            },
2274                                            {"type": "text", "text": "Image from tool execution."} # Context is helpful
2275                                        ]
2276                                    else:
2277                                        tool_message_content = str(tool_result.output)
2278            
2279                                    tool_message = ToolMessage(
2280                                        content=tool_message_content,
2281                                        tool_call_id=tool_id,
2282                                        name=tool_name
2283                                    )
2284                                    conversation_history.append(tool_message)
2285                                #####
2286                                else:
2287                                    # Anthropic and others use the original format
2288                                    conversation_history.append(
2289                                        AIMessage(
2290                                            content=[{
2291                                                "type": "tool_use",
2292                                                "id": tool_id,
2293                                                "name": tool_name,
2294                                                "input": tool_args
2295                                            }]
2296                                        )
2297                                    )
2298                                    
2299                                    # THIS BLOCK NEEDS TO BE IF TOOLRESULT ELSE TOOLERROR
2300                                    if tool_result.error:
2301                                        tool_message_content = str(tool_result.error)
2302                                            # Store current system message position for reference
2303                                        sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2304                                        # Swap to tool debug mode
2305                                        conversation_history[sys_msg_idx] = SystemMessage(content=self.tool_sysmsg)
2306                                    elif tool_result.base64_image:
2307                                        tool_message_content=[{
2308                                            'type': 'image', 
2309                                            'source': {
2310                                                'type': 'base64', 
2311                                                'media_type': 'image/png', 
2312                                                'data': f"{tool_result.base64_image}"
2313                                                }
2314                                            },
2315                                            {"type": "text", "text": "Describe this image."}
2316                                        ]
2317                                    else:
2318                                        # We know tool_result.output exists because _arun guarantees either
2319                                        # error or output will be set
2320                                        tool_message_content = str(tool_result.output)
2321
2322                                    conversation_history.append(
2323                                        ToolMessage(
2324                                            # content=str(tool_result.output),  # Just the output string
2325                                            content=tool_message_content,
2326                                            tool_call_id=tool_id,
2327                                            name=tool_name
2328                                        )
2329                                    )
2330                                    # treverse the conversation_history and remove any all screen shot tool message 
2331                                    # except for the current one we just added (fix > 200,000 token issue)
2332                                    # for item in conversation_history[-2::-1]:
2333                                    #     if len(str(item.content)) > 100000:
2334                                    #         item.content="Removed old image that is no longer needed"
2335                                ### Unindented these 2 tabs
2336                                with open(tool_log_path, 'a') as f:
2337                                    f.write(f"\nAbout to call tool_output_callback with result: {tool_result.output}\n")
2338                                    f.write(f"tool_id: {tool_id}\n")
2339                                
2340                                tool_output_callback(tool_result, tool_id)
2341                                if heaven_main_callback:
2342                                    heaven_main_callback(conversation_history[-1]) # the tool message
2343                                with open(tool_log_path, 'a') as f:
2344                                    f.write("After tool_callback\n")
2345                                   
2346    
2347                                print("\n=== AFTER ADDING TOOL MESSAGES ===")
2348                                for i, msg in enumerate(conversation_history):
2349                                    print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2350    
2351                                # Get AI's response about the tool result
2352                                # print("\n=== GETTING AI RESPONSE ABOUT TOOL RESULT ===")
2353                                with open(tool_log_path, 'a') as f:
2354                                    f.write(f"\nAI should be called next...\n")
2355                                    f.write(f"Coversation_History:\n    {conversation_history}\n")
2356                                result_response = await self.chat_model.ainvoke(conversation_history)
2357                                
2358                                sys_msg_idx = next(i for i, msg in enumerate(conversation_history) if isinstance(msg, SystemMessage))
2359                                conversation_history[sys_msg_idx] = SystemMessage(content=self.config.system_prompt)
2360                                if heaven_main_callback:
2361                                        heaven_main_callback(result_response)
2362                                if result_response:
2363                                    with open(tool_log_path, 'a') as f:
2364                                        f.write(f"\nGot AI response after tool: {result_response.content}\n")
2365                                # print(f"===Result response===:\n\n{result_response}\n\n===/result response===")
2366                                ###### Add output callback here
2367                                
2368    
2369                                # if (
2370                                #     self.config.provider == ProviderEnum.GOOGLE              # Gemini
2371                                #     and isinstance(result_response, AIMessage)
2372                                #     and (
2373                                #         result_response.tool_calls                                  # standard field
2374                                #         or result_response.additional_kwargs.get("tool_calls")      # legacy field
2375                                #     )
2376                                # ):
2377                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage) and not result_response.content and (result_response.tool_calls or result_response.additional_kwargs.get('tool_calls')): # Check if tool calls exist
2378                                if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2379                                    
2380                                    
2381                                    # Clean response for conversation_history
2382                                    if isinstance(result_response.content, list):
2383                                        # Extract only text, ignore thinking blocks
2384                                        text_content = []
2385                                        for item in result_response.content:
2386                                            if isinstance(item, str):
2387                                                text_content.append(item)
2388                                            elif isinstance(item, dict) and item.get('type') == 'text':
2389                                                text_content.append(item.get('text', ''))
2390                                        
2391                                        # Create cleaned response with simple string content
2392                                        cleaned_result_response = AIMessage(
2393                                            content=' '.join(text_content),  # Simple string, not list!
2394                                            additional_kwargs=result_response.additional_kwargs,
2395                                            tool_calls=result_response.tool_calls if hasattr(result_response, 'tool_calls') else []
2396                                        )
2397                                        conversation_history.append(cleaned_result_response)
2398                                    else:
2399                                        conversation_history.append(result_response)
2400                                # if self.config.provider == ProviderEnum.GOOGLE and isinstance(result_response, AIMessage):
2401                                #     # --- THE FIX ---
2402                                #     # Append the ORIGINAL response object. LangChain needs its structure.
2403                                #     conversation_history.append(result_response)
2404                                #     # self._process_agent_response(result_response)
2405                                #     output_callback(result_response)
2406                                #     # if heaven_main_callback:
2407                                #     #     heaven_main_callback(result_response)
2408                                # else:
2409                                elif self.config.provider not in (ProviderEnum.GOOGLE,):
2410                                    if isinstance(result_response.content, list):
2411                                        thinking_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'thinking']
2412                                        if thinking_content:
2413                                            message3 = AIMessage(content=thinking_content)
2414                                            conversation_history.append(message3)
2415                                        text_content = [block for block in result_response.content if isinstance(block, dict) and block.get('type') == 'text']
2416                                        if text_content:
2417                                            message = AIMessage(content=text_content)
2418                                            conversation_history.append(message)
2419                                            self._process_agent_response(text_content)
2420                                            output_callback(message)  # Pass the AIMessage object
2421                                            # if heaven_main_callback:
2422                                            #     heaven_main_callback(message)
2423                                        tool_use_content = [block for block in result_response.content if block.get('type') == 'tool_use']
2424                                        if tool_use_content:
2425                                            message2 = AIMessage(content=tool_use_content)
2426                                            output_callback(message2)
2427                                            # if heaven_main_callback:
2428                                            #     heaven_main_callback(message2)
2429                                    elif isinstance(result_response.content, str):
2430                                        message = AIMessage(content=result_response.content)
2431                                        conversation_history.append(message)
2432                                        self._process_agent_response(result_response.content)
2433                                        output_callback(message)  # Pass the AIMessage object
2434                                        # if heaven_main_callback:
2435                                        #     heaven_main_callback(message)
2436                                
2437                                # print("\n=== CONVERSATION HISTORY AFTER AI RESPONSE ABOUT TOOL ===")
2438                                # for i, msg in enumerate(conversation_history):
2439                                #     print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2440
2441                                # This may be redundant
2442                                # Process the AI's commentary if in agent mode
2443                                if isinstance(result_response, AIMessage):
2444                                    self._process_agent_response(result_response.content)
2445
2446                                # Now check whether the result_response includes new tool calls
2447                                new_calls = []
2448                                try:
2449                                    if hasattr(result_response, 'tool_calls'):
2450                                        new_calls = result_response.tool_calls
2451                                    elif isinstance(result_response.content, list):
2452                                        new_calls = [
2453                                            item for item in result_response.content 
2454                                            if isinstance(item, dict) and item.get('type') == 'tool_use'
2455                                        ]
2456                                    elif 'tool_calls' in result_response.additional_kwargs:
2457                                        new_calls = result_response.additional_kwargs['tool_calls']
2458                                except Exception as e:
2459                                    print(f"Error examining result_response: {e}")
2460    
2461                                if new_calls:
2462                                    new_tool_calls.extend(new_calls)
2463    
2464                                tool_call_count += 1
2465                                if tool_call_count >= self.max_tool_calls:
2466                                    # NEW: Handle any pending tool calls that won't be processed
2467                                    # if current_tool_calls:
2468                                    #     # There are still tool calls queued that we're about to abandon
2469                                    #     for pending_tool in current_tool_calls:
2470                                    #         # Extract the tool info (handle different formats)
2471                                    #         if 'function' in pending_tool:
2472                                    #             tool_id = pending_tool.get('id', '')
2473                                    #             tool_name = pending_tool['function']['name']
2474                                    #         else:
2475                                    #             tool_id = pending_tool.get('id', '')
2476                                    #             tool_name = pending_tool.get('name', '')
2477                                            
2478                                    #         # Inject dummy ToolMessage for each orphaned call
2479                                    #         dummy_msg = ToolMessage(
2480                                    #             content="Error: The underlying system stopped this tool call from completing. It was interrupted. Once the user responds, the tool count will be reset.",
2481                                    #             tool_call_id=tool_id,
2482                                    #             name=tool_name
2483                                    #         )
2484                                    #         conversation_history.append(dummy_msg)
2485                                    #### NEW
2486                                        # Add a message informing the AI that max tool count was reached
2487                                    break_message = f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}. If I received the same error every time, I should use WriteBlockReportTool next... Waiting for next iteration."
2488                                    break_ai_message = AIMessage(content=break_message)
2489                                    conversation_history.append(break_ai_message)
2490                                    output_callback(break_message)
2491                                    if heaven_main_callback:
2492                                        heaven_main_callback(break_ai_message)
2493                                    # Clear the tool queue for this iteration
2494                                    current_tool_calls = []
2495                                    print(f"Maximum tool calls ({self.max_tool_calls}) reached for iteration {self.current_iteration}")
2496                                    break  # Exit tool loop and continue to next iteration
2497                            else:
2498                                print(f"No matching tool found for {tool_name}")
2499                        except Exception as e:
2500                            print(f"Error processing tool call: {tool_call}")
2501                            print(f"Error details: {e}")
2502                            continue
2503                    # Prepare to process any new tool calls that came in the follow-up response
2504                    current_tool_calls = new_tool_calls
2505    
2506                
2507                
2508                # Process the agent response if in agent mode
2509                if self.goal and isinstance(response, AIMessage):
2510                    self._process_agent_response(response.content)
2511                    
2512                    # print(f"\nDEBUG TASK STATE: current_task={self.current_task}, current_iteration={self.current_iteration}, max_iterations={self.max_iterations}, task_list={self.task_list}")
2513    
2514                # Increment iteration count and break if the goal is met
2515                self.current_iteration += 1
2516    
2517                if self.current_task == "GOAL ACCOMPLISHED" or not self.goal:
2518                    break
2519    
2520            # Update final history and return
2521            print("\n=== FINAL HISTORY ===")
2522            for i, msg in enumerate(conversation_history):
2523                print(f"Message {i}: {type(msg).__name__} - {msg.content}")
2524            self.history.messages = conversation_history
2525            self.look_for_particular_tool_calls()
2526            self.history.messages = conversation_history
2527            # Save history and get potentially new history_id
2528            try:
2529                
2530                # print("=== DEBUG: BEFORE SAVE ATTEMPT ===")
2531                # print(f"Agent name: {self.name}")
2532                # print(f"Current history: {self.history}")
2533                self.history.agent_status = self.save_status()
2534                saved_history_id = self.history.save(self.name)
2535                # print("===DEBUG AFTER SAVE ATTEMPT===")
2536                self.look_for_particular_tool_calls()
2537                return {
2538                    "history": self.history,
2539                    "history_id": saved_history_id,
2540                    "agent_name": self.name,
2541                    "agent_status": self.history.agent_status  # Add this
2542                }
2543            except Exception as save_error:
2544                # print("=== DEBUG: SAVE ERROR OCCURRED ===")
2545                print(f"Error type: {type(save_error)}")
2546                print(f"Error message: {str(save_error)}")
2547                # Log the error but don't fail the run
2548                print(f"Warning: Failed to save history for agent {self.name}: {save_error}")
2549                
2550                return {
2551                    "history": self.history,
2552                    "history_id": getattr(self.history, 'history_id', "No history ID"),
2553                    "agent_name": self.name,
2554                    "save_error": str(save_error),
2555                    "agent_status": self.save_status()  # Add this here too
2556                }
2557            return self.history
2558    
2559        except Exception as e:
2560            raise RuntimeError(f"Agent run failed: {str(e)}") from e
async def run_on_uni_api( self, prompt: Optional[str] = None, output_callback: Optional[Callable] = None, tool_output_callback: Optional[Callable] = None, heaven_main_callback: Optional[Callable] = None) -> Dict[str, Any]:
3037    async def run_on_uni_api(
3038        self,
3039        prompt: Optional[str] = None,
3040        output_callback: Optional[Callable] = None,
3041        tool_output_callback: Optional[Callable] = None,
3042        heaven_main_callback: Optional[Callable] = None,
3043    ) -> Dict[str, Any]:
3044        """
3045        Run agent using uni-api instead of LangChain providers.
3046        Enforces “one tool call at a time” without altering your
3047        original stop-message wording.
3048        """
3049        result = None
3050        # ---------- 0.  History bootstrap ----------
3051        self._current_output_callback = output_callback
3052        self._current_tool_callback = tool_output_callback
3053
3054        uni_conversation_history = self.history.to_uni_messages()
3055        langchain_conversation_history = self.history.messages.copy()
3056
3057        if not uni_conversation_history or uni_conversation_history[0]["role"] != "system":
3058            uni_conversation_history.insert(0, {"role": "system", "content": self.config.system_prompt})
3059            langchain_conversation_history.insert(0, SystemMessage(content=self.config.system_prompt))
3060        elif uni_conversation_history[0]["content"] != self.config.system_prompt:
3061            uni_conversation_history[0]["content"] = self.config.system_prompt
3062            langchain_conversation_history[0] = SystemMessage(content=self.config.system_prompt)
3063
3064        if prompt:
3065            uni_conversation_history.append({"role": "user", "content": prompt})
3066            langchain_conversation_history.append(HumanMessage(content=prompt))
3067            self._detect_agent_command(prompt)
3068
3069        # Resolve MCP tools before preparing for API
3070        await self.resolve_mcps()
3071
3072        openai_tools = self._prepare_tools_for_uni_api()
3073        self.blocked = False
3074
3075        # ---------- 1.  Iteration loop ----------
3076        while self.current_iteration <= self.max_iterations:
3077            tool_call_count = 0
3078            self._cleanse_dangling_tool_calls(
3079                uni_conversation_history, langchain_conversation_history, "MULTIPLE_TOOL_CALLS"
3080            )
3081
3082            if self.goal:
3083                agent_prompt = self._format_agent_prompt()
3084                uni_conversation_history.append({"role": "user", "content": agent_prompt})
3085                langchain_conversation_history.append(HumanMessage(content=agent_prompt))
3086
3087            payload = {
3088                "max_tokens": getattr(self.config, "max_tokens", 4000),
3089                "temperature": getattr(self.config, "temperature", 0.7),
3090                "parallel_tool_calls": False,
3091            }
3092            if openai_tools:
3093                payload["tools"] = openai_tools
3094                payload["tool_choice"] = "auto"
3095
3096            result = self.unified_chat.invoke_uni_api(
3097                model=self.config.model, uni_messages=uni_conversation_history, **payload
3098            )
3099            assistant_message = result["choices"][0]["message"]
3100
3101            # ---------- 2.  Single-tool clamp ----------
3102            self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3103
3104            # ---------- 3.  Tool-call branch ----------
3105            if assistant_message.get("tool_calls"):
3106                if assistant_message.get("content") is None:
3107                    assistant_message["content"] = ""
3108
3109                uni_conversation_history.append(assistant_message)
3110                langchain_conversation_history.append(
3111                    AIMessage(content="", additional_kwargs={"tool_calls": assistant_message["tool_calls"]})
3112                )
3113                if output_callback:
3114                    output_callback(langchain_conversation_history[-1])
3115                if heaven_main_callback:
3116                    heaven_main_callback(langchain_conversation_history[-1])
3117
3118                while assistant_message.get("tool_calls") and tool_call_count < self.max_tool_calls:
3119                    # execute the (single) tool call
3120                    tool_messages = await self._execute_tool_calls_uni(
3121                        assistant_message["tool_calls"], tool_output_callback
3122                    )
3123                    uni_conversation_history.extend(tool_messages)
3124                    for tm in tool_messages:
3125                        langchain_conversation_history.append(
3126                            ToolMessage(content=tm["content"], tool_call_id=tm["tool_call_id"])
3127                        )
3128
3129                    # Check if WriteBlockReportTool was called and auto-inject response
3130                    if self.blocked:
3131                        # Extract the required response from WriteBlockReportTool result
3132                        for tm in tool_messages:
3133                            if tm.get("name") == "WriteBlockReportTool":
3134                                response_msg = "I've created a block report and am waiting for the help I need"
3135                                
3136                                # Add to uni conversation layer
3137                                uni_conversation_history.append({
3138                                    "role": "assistant", 
3139                                    "content": response_msg
3140                                })
3141                                
3142                                # Add to langchain layer  
3143                                langchain_conversation_history.append(
3144                                    AIMessage(content=response_msg)
3145                                )
3146                                break
3147                        break
3148
3149                    tool_result = self.unified_chat.invoke_uni_api(
3150                        model=self.config.model, uni_messages=uni_conversation_history, **payload
3151                    )
3152                    assistant_message = tool_result["choices"][0]["message"]
3153                    
3154                    # ---------- 4.  Clamp again / MAX_TOOL_CALLS ----------
3155                    cancelled_tools = []  # Initialize to prevent NameError
3156                    if tool_call_count + 1 >= self.max_tool_calls and assistant_message.get("tool_calls"):
3157                        cancelled_tools = [
3158                            f"{tc['function']['name']}({tc['function']['arguments']})"
3159                            for tc in assistant_message["tool_calls"]
3160                        ]
3161                        assistant_message.pop("tool_calls", None)  # strip them but keep same dict
3162                    else:
3163                        self._cleanse_dangling_tool_calls([assistant_message], [], "MULTIPLE_TOOL_CALLS")
3164
3165                    if assistant_message.get("content") is None:
3166                        assistant_message["content"] = ""
3167                    result = assistant_message
3168                    uni_conversation_history.append(assistant_message)
3169                    langchain_conversation_history.append(
3170                        self.history.from_uni_messages([assistant_message]).messages[0]
3171                    )
3172                    if output_callback:
3173                        output_callback(langchain_conversation_history[-1])
3174                    if heaven_main_callback:
3175                        heaven_main_callback(assistant_message)
3176
3177                    tool_call_count += 1
3178
3179                    if tool_call_count >= self.max_tool_calls:
3180                        break_message = {
3181                            "role": "assistant",
3182                            "content": (
3183                                f"⚠️🛑☠️ Maximum consecutive tool calls ({self.max_tool_calls}) "
3184                                f"reached after agent mode iteration {self.current_iteration}. "
3185                                f"I tried to call [{', '.join(cancelled_tools)}] but they were "
3186                                "cancelled by the system. If I received the same error every time, "
3187                                "I should use WriteBlockReportTool next... Waiting for next agent "
3188                                "mode iteration."
3189                            ),
3190                        }
3191                        uni_conversation_history.append(break_message)
3192                        langchain_conversation_history.append(
3193                            self.history.from_uni_messages([break_message]).messages[0]
3194                        )
3195                        if output_callback:
3196                            output_callback(langchain_conversation_history[-1])
3197                        break
3198
3199            # ---------- 5.  Text-only branch ----------
3200            else:
3201                if assistant_message.get("content") is None:
3202                    assistant_message["content"] = ""
3203                uni_conversation_history.append(assistant_message)
3204                langchain_conversation_history.append(
3205                    self.history.from_uni_messages([assistant_message]).messages[0]
3206                )
3207                if output_callback:
3208                    output_callback(langchain_conversation_history[-1])
3209                if heaven_main_callback:
3210                    heaven_main_callback(langchain_conversation_history[-1])
3211
3212            # ---------- 6.  Agent-mode bookkeeping ----------
3213            if self.goal and assistant_message.get("content"):
3214                self._process_agent_response(assistant_message["content"])
3215
3216            self._cleanse_dangling_tool_calls(
3217                uni_conversation_history, langchain_conversation_history, "MAX_TOOL_CALLS"
3218            )
3219
3220            self.current_iteration += 1
3221            if self.current_task == "GOAL ACCOMPLISHED" or not self.goal or self.blocked:
3222                break
3223
3224        # ---------- 7.  Persist history ----------
3225        self.history.messages = langchain_conversation_history
3226        self.history.agent_status = self.save_status()
3227        saved_history_id = self.history.save(self.name)
3228
3229        return {
3230            "history": self.history,
3231            "history_id": saved_history_id,
3232            "agent_name": self.name,
3233            "agent_status": self.history.agent_status,
3234            "uni_api_used": True,
3235            "context_window_status": self.context_window_config.get_status(),
3236            "raw_response": result,
3237        }

Run agent using uni-api instead of LangChain providers. Enforces “one tool call at a time” without altering your original stop-message wording.

def save_status(self) -> heaven_base.memory.history.AgentStatus:
3450    def save_status(self) -> AgentStatus:
3451        """Package current agent state into status object"""
3452        if self._current_extracted_content is not None:
3453            extracts = self._current_extracted_content
3454            return AgentStatus(
3455                goal=self.goal,
3456                task_list=self.task_list.copy(),
3457                current_task=self.current_task,
3458                completed=self.completed,
3459                extracted_content=extracts
3460            )
3461        else:
3462            
3463            return AgentStatus(
3464                goal=self.goal,
3465                task_list=self.task_list.copy(),
3466                current_task=self.current_task,
3467                completed=self.completed
3468            )

Package current agent state into status object

def load_status(self, status: heaven_base.memory.history.AgentStatus):
3472    def load_status(self, status: AgentStatus):
3473        """Load agent state from status object"""
3474        self.goal = status.goal
3475        self.task_list = status.task_list.copy()
3476        self.current_task = status.current_task
3477        self.max_iterations = status.max_iterations
3478        self.current_iteration = status.current_iteration

Load agent state from status object

async def continue_iterations( self, history_id: str, continuation_iterations: Optional[int] = 0, continuation_prompt: str = None):
3481    async def continue_iterations(self, history_id: str, continuation_iterations: Optional[int] = 0, continuation_prompt: str = None):
3482        """Continue work from a saved history"""
3483        self.history = History.load_from_id(history_id)
3484        
3485        if self.history.agent_status:
3486            # Load goal, tasks, etc from status
3487            self.goal = self.history.agent_status.goal
3488            self.task_list = self.history.agent_status.task_list
3489            self.current_task = self.history.agent_status.current_task
3490            self.completed = False  # Reset completion for new run
3491            self.continuation_prompt = continuation_prompt if continuation_prompt is not None else ""
3492            self.continuation_iterations = continuation_iterations
3493            if self.goal is None:
3494                self.goal = self.continuation_prompt # this should mean goal is never none even if history has no goal
3495            if self.goal == "":
3496                self.goal = None
3497            return await self.run()  # Continue with current state
3498        else:
3499            raise ValueError("No agent status found in history")

Continue work from a saved history

def reset(self):
3501    def reset(self):
3502        """Reset the agent's internal state."""
3503        self.goal = None
3504        self.task_list = []
3505        self.current_task = None
3506        self.max_iterations = None
3507        self.current_iteration = 0

Reset the agent's internal state.

def look_for_particular_tool_calls(self) -> None:
3509    def look_for_particular_tool_calls(self) -> None:
3510        """Hook for agents to process specific tool calls and their results"""
3511        pass

Hook for agents to process specific tool calls and their results

def create_block_report(self):
3522    def create_block_report(self):
3523        # look up the json file
3524        block_report_path = "/tmp/block_report.json"
3525        
3526        # Check if the block report file was created
3527        if os.path.exists(block_report_path):            
3528            # Read and display the file contents
3529            with open(block_report_path, 'r') as f:
3530                report_data = json.load(f)
3531                print("Block Report Content:")
3532                for key, value in report_data.items():
3533                    print(f"  {key}: {value}")
3534                    # this is wrong, old code. i want to make it vars so i can create a markdown file
3535            
3536            # pull the stuff out and make it vars
3537           
3538            completed_tasks = report_data.get("completed_tasks", "N/A")
3539            current_task = report_data.get("current_task", "N/A")
3540            explanation = report_data.get("explanation", "No explanation provided.")
3541            blocked_reason = report_data.get("blocked_reason", "No blocked reason provided.")
3542            timestamp = report_data.get("timestamp", "Unknown timestamp")
3543          
3544            agent_task = self.current_task if self.current_task is not None else ""
3545            agent_goal = self.goal if self.goal is not None else ""
3546            truncated_goal = agent_goal[:200] + " <truncated for brevity - history_id file has full goal if you need to see it (this is unlikely)>" if len(agent_goal) > 200 else agent_goal
3547            # history_id = getattr(self.history, 'history_id', "No history ID") # this is impossible because of when the history_id is made. Instead, we provide it in the Hermes call metadata
3548            # inject to the places it should go in the markdown
3549            md_block_report = f"""
3550# 🛑🚧📃
3551## === BLOCKED REPORT ===
3552The agent encountered a blocking obstacle while working toward the goal.
3553
3554## 📝 Report Metadata  
3555- **⏰ Time of Report**: `{timestamp}`  
3556- **��� Report Filed By**: `{self.name}`   
3557
3558---
3559
3560## 🎯 Task Overview  
3561- **🏆 Goal**: `{truncated_goal}`  
3562- **✅ Self-reported Completed Tasks**: `{completed_tasks}`  
3563- **📌 Internally Tracked Current Task Value**: `{agent_task}`  
3564- **����� Agent's Perceived Current Task**: `{current_task}`. 
3565_(If there is a mismatch between this and the internally tracked task, the agent may not have fully followed tasking instructions.)_
3566
3567---
3568
3569## 🧐 Agent's Explanation  
3570**💬 The agent provided this explanation for the blockage:** 
3571```
3572{explanation}
3573```
3574
3575---
3576
3577## 🚨 Blocked Reason  
3578**⚠️ The agent explained the reason why it is blocked as follows:**  
3579```
3580{blocked_reason}
3581```
3582
3583## === /BLOCKED REPORT ===
3584
3585---
3586
3587## 📢 What To Do Next   
3588- 🛠️ **If you recognize the issue, offer potential solutions before asking the user for input.**
3589- 🧑‍����� **Check with the user to see if they already know how to resolve the issue.** 
3590- 🚦 **Do NOT use any tools before talking to the user.**  
3591  _(This should remain a HITL (Human-in-the-Loop) interaction.)_  
3592- ��🔁 **Once a potential solution route is found through discussion with the user,** activate a continuation call to continue the work (make sure to use the history_id provided).  
3593
3594"""
3595
3596        
3597        
3598            # Cleanup
3599            os.remove(block_report_path)
3600            return md_block_report
3601        else:
3602            print("Block report file not found.")
3603            return None
class BaseHeavenAgentReplicant(BaseHeavenAgent):
3609class BaseHeavenAgentReplicant(BaseHeavenAgent):
3610    @classmethod
3611    def get_default_config(cls) -> HeavenAgentConfig:
3612        """Each subclass should override this to provide its default config"""
3613        raise NotImplementedError("Subclasses must implement get_default_config")
3614
3615    def __init__(self, 
3616                 config: Optional[HeavenAgentConfig] = None, 
3617                 chat: Optional[UnifiedChat] = None,
3618                 history_id: Optional[str] = None,
3619                 orchestrator: bool = False,
3620                 system_prompt_suffix: Optional[str] = None,
3621                 additional_tools: Optional[List[Type[BaseHeavenTool]]] = None,
3622                 remove_agents_config_tools: bool = False,
3623                 duo_enabled: bool = False,
3624                 run_on_langchain: bool = False,
3625                 adk: bool = False,
3626                 use_uni_api: bool = False
3627                ):
3628        # If no config provided, use the class's default
3629        _config = config or self.get_default_config()
3630            # Handle additional tools and tool removal
3631        if additional_tools:
3632            if remove_agents_config_tools:
3633                # Replace all tools
3634                _config.tools = additional_tools
3635            else:
3636                # Add to existing tools without duplicates
3637                for tool_class in additional_tools:
3638                    if tool_class not in _config.tools:
3639                        _config.tools.append(tool_class)
3640        if system_prompt_suffix:  # Add this
3641            _config.system_prompt += system_prompt_suffix
3642        # If no chat provided, create a new UnifiedChat
3643        _chat = chat or UnifiedChat()
3644        super().__init__(_config, _chat, history_id=history_id, orchestrator=orchestrator, duo_enabled=duo_enabled, run_on_langchain=run_on_langchain, adk=adk, use_uni_api=use_uni_api)

Base class for GOD Framework agents with task management.

BaseHeavenAgentReplicant( config: Optional[HeavenAgentConfig] = None, chat: Optional[heaven_base.unified_chat.UnifiedChat] = None, history_id: Optional[str] = None, orchestrator: bool = False, system_prompt_suffix: Optional[str] = None, additional_tools: Optional[List[Type[heaven_base.baseheaventool.BaseHeavenTool]]] = None, remove_agents_config_tools: bool = False, duo_enabled: bool = False, run_on_langchain: bool = False, adk: bool = False, use_uni_api: bool = False)
3615    def __init__(self, 
3616                 config: Optional[HeavenAgentConfig] = None, 
3617                 chat: Optional[UnifiedChat] = None,
3618                 history_id: Optional[str] = None,
3619                 orchestrator: bool = False,
3620                 system_prompt_suffix: Optional[str] = None,
3621                 additional_tools: Optional[List[Type[BaseHeavenTool]]] = None,
3622                 remove_agents_config_tools: bool = False,
3623                 duo_enabled: bool = False,
3624                 run_on_langchain: bool = False,
3625                 adk: bool = False,
3626                 use_uni_api: bool = False
3627                ):
3628        # If no config provided, use the class's default
3629        _config = config or self.get_default_config()
3630            # Handle additional tools and tool removal
3631        if additional_tools:
3632            if remove_agents_config_tools:
3633                # Replace all tools
3634                _config.tools = additional_tools
3635            else:
3636                # Add to existing tools without duplicates
3637                for tool_class in additional_tools:
3638                    if tool_class not in _config.tools:
3639                        _config.tools.append(tool_class)
3640        if system_prompt_suffix:  # Add this
3641            _config.system_prompt += system_prompt_suffix
3642        # If no chat provided, create a new UnifiedChat
3643        _chat = chat or UnifiedChat()
3644        super().__init__(_config, _chat, history_id=history_id, orchestrator=orchestrator, duo_enabled=duo_enabled, run_on_langchain=run_on_langchain, adk=adk, use_uni_api=use_uni_api)
@classmethod
def get_default_config(cls) -> HeavenAgentConfig:
3610    @classmethod
3611    def get_default_config(cls) -> HeavenAgentConfig:
3612        """Each subclass should override this to provide its default config"""
3613        raise NotImplementedError("Subclasses must implement get_default_config")

Each subclass should override this to provide its default config

def get_agent_by_name( agent_name: str) -> Union[BaseHeavenAgent, BaseHeavenAgentReplicant]:
3649def get_agent_by_name(agent_name: str) -> Union[BaseHeavenAgent, BaseHeavenAgentReplicant]:
3650    """
3651    Gets an initialized agent instance by name, handling both replicant and config approaches.
3652    Focuses only on loading the agent based on its definition, not runtime modifications.
3653
3654    Args:
3655        agent_name: Name of the agent to load (can be PascalCase or snake_case).
3656
3657    Returns:
3658        Initialized agent instance.
3659
3660    Raises:
3661        ValueError: If the agent cannot be loaded via either method.
3662    """
3663    # Normalize the input name to snake_case for path construction
3664    agent_name_snake = normalize_agent_name(agent_name)
3665    print(f"[get_agent_by_name] Normalized '{agent_name}' to '{agent_name_snake}'")
3666
3667    # --- Try Replicant approach first ---
3668    try:
3669        # Construct path based on normalized name - use heaven-framework paths
3670        module_path = f"heaven_base.agents.{agent_name_snake}.{agent_name_snake}"
3671        print(f"[get_agent_by_name] Trying Replicant module: {module_path}")
3672        agent_module = importlib.import_module(module_path)
3673
3674        # Convert original agent_name to PascalCase for class lookup
3675        # (Assuming class name follows PascalCase derived from the conceptual name)
3676        pascal_name = ''.join(word.capitalize() for word in agent_name_snake.split('_'))
3677        # Handle potential "Agent" suffix duplication if agent_name already had it
3678        if not pascal_name.endswith("Agent"):
3679             pascal_name += "Agent" # Ensure standard suffix if derived name doesn't have it
3680        # A cleaner way might be to derive PascalCase directly from the original agent_name input
3681        # pascal_name = derive_pascal_case(agent_name) # If you have such a util
3682
3683        print(f"[get_agent_by_name] Looking for Replicant class: {pascal_name}")
3684        agent_class = getattr(agent_module, pascal_name)
3685
3686        # Initialize the replicant with NO runtime modifications here
3687        # Replicant's __init__ should handle its default tools/prompt
3688        agent = agent_class() # Assumes basic init doesn't require args here
3689        print(f"[get_agent_by_name] Replicant approach successful for {agent_name}.")
3690        return agent
3691
3692    except ModuleNotFoundError:
3693        print(f"[get_agent_by_name] Replicant module not found for {agent_name_snake}.")
3694    except AttributeError:
3695         print(f"[get_agent_by_name] Replicant class '{pascal_name}' not found in module.")
3696    except Exception as e_rep:
3697        print(f"[get_agent_by_name] Replicant approach failed unexpectedly for {agent_name}: {type(e_rep).__name__} - {e_rep}")
3698        # print(traceback.format_exc()) # Optional: Print full traceback for debugging
3699
3700    # --- If Replicant failed, try config approach ---
3701    print(f"[get_agent_by_name] Trying Config approach for {agent_name_snake}...")
3702    try:
3703        # Construct path and config object name based on normalized name - use heaven-framework paths
3704        config_object_name = f"{agent_name_snake}_config"
3705        config_module_path = f"heaven_base.agents.{config_object_name}"
3706        print(f"[get_agent_by_name] Trying Config module: {config_module_path}")
3707
3708        config_module = importlib.import_module(config_module_path)
3709        print(f"[get_agent_by_name] Looking for Config object: {config_object_name}")
3710        config = getattr(config_module, config_object_name)
3711
3712        # Create agent using the loaded config object directly
3713        # Do NOT apply suffix or tool modifications here
3714        agent = BaseHeavenAgent(config, UnifiedChat()) # Assumes orchestrator=False default
3715        print(f"[get_agent_by_name] Config approach successful for {agent_name}.")
3716        return agent
3717
3718    except ModuleNotFoundError:
3719        print(f"[get_agent_by_name] Config module not found for {agent_name_snake}.")
3720    except AttributeError:
3721        print(f"[get_agent_by_name] Config object '{config_object_name}' not found in module.")
3722    except Exception as e_conf:
3723        print(f"[get_agent_by_name] Config approach failed unexpectedly for {agent_name}: {type(e_conf).__name__} - {e_conf}")
3724        # print(traceback.format_exc()) # Optional: Print full traceback
3725
3726    # If both approaches failed
3727    raise ValueError(f"Failed to load agent '{agent_name}' using either Replicant or Config approach.")

Gets an initialized agent instance by name, handling both replicant and config approaches. Focuses only on loading the agent based on its definition, not runtime modifications.

Args: agent_name: Name of the agent to load (can be PascalCase or snake_case).

Returns: Initialized agent instance.

Raises: ValueError: If the agent cannot be loaded via either method.