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.")
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 )
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
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'.
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
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
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)
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/
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.
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
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.
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.
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
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
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
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
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)
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.
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
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.
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
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
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
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
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
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.
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().
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)
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
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
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.
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
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
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
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.
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
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
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.
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)
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
Inherited Members
- BaseHeavenAgent
- 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
- 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
- task_list
- current_task
- max_iterations
- current_iteration
- completed
- orchestration_lists
- configs_prompt
- tool_sysmsg
- system_prompt_evolved
- history
- resolve_tools
- resolve_mcps
- async_init
- load_mcp_tools
- to_base_tools
- refresh_system_prompt
- resolve_duo
- run_adk
- run
- run_langchain
- streamlit_run
- run_on_uni_api
- save_status
- load_status
- continue_iterations
- reset
- look_for_particular_tool_calls
- create_block_report
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.