# Fix Windows encoding for non-ASCII characters (Telugu, emojis, etc.)
import sys
# Windows: use the SelectorEventLoop policy process-wide. autobahn's asyncio
# WAMP transport breaks on the default Windows ProactorEventLoop — the
# EventBus WAMP bridge (core/platform/events.py connect_wamp) opens the TCP
# socket to the relay but the session never joins (on_join never fires), so
# the internet relay stays dark on Windows while a macOS/Linux peer connects
# fine. autobahn resolves its loop through the global policy, so this must be
# set before any loop is created; hypercorn serves cleanly on a selector loop.
if sys.platform == 'win32':
import asyncio as _asyncio_boot
_asyncio_boot.set_event_loop_policy(_asyncio_boot.WindowsSelectorEventLoopPolicy())
from core.subprocess_safe import run_bounded
from core.platform_paths import under_test as _under_test
import io
if sys.platform == 'win32' and not _under_test():
# Force UTF-8 encoding for stdout/stderr to prevent crashes with non-ASCII characters
# Skip when running under pytest — pytest wraps stdout/stderr for capture,
# and replacing them here closes pytest's file handles.
# Skip in cx_Freeze windowed builds — sys.stdout.buffer is closed (no console),
# and TextIOWrapper on a closed buffer raises ValueError at import time.
# Only re-wrap a stream that is NOT already UTF-8. The hasattr('buffer')
# test above is NOT sufficient to skip frozen builds: app.py installs a
# _CappedStream as sys.stdout/stderr, and its __getattr__ delegates every
# unknown attribute to the inner file handle -- so `hasattr(.., 'buffer')`
# is True and `.buffer` hands back the INNER handle. Wrapping that inner
# handle escapes the wrapper: when _CappedStream._rotate() later closes and
# reopens the inner handle at the 20MB cap, this TextIOWrapper is left
# holding the CLOSED one, and every write after that raises
# "ValueError: I/O operation on closed file" for the rest of the process.
# The try/except below cannot catch it either -- at import time the buffer
# is still open, so the wrap succeeds and the failure lands hours later.
#
# Measured 2026-08-29 on the installed build: frozen_debug.log rotated at
# 08:37:09, and both langchain tool turns after it (08:58:17, 09:04:27)
# died in warnings.warn -> sys.stderr.write during LLMChain's deprecation
# warning, so /chat 500'd and fell back to a tool-less '_tier: direct'.
# This is the consumer that task #621 predicted but never located.
#
# _CappedStream and the devnull fallbacks are all opened encoding='utf-8',
# so skipping them loses nothing; a real cp1252 Windows console still gets
# wrapped, which is the only case this code was written for.
def _needs_utf8_wrap(stream):
if stream is None or not hasattr(stream, 'buffer'):
return False
enc = (getattr(stream, 'encoding', '') or '').lower().replace('-', '')
return enc != 'utf8'
if _needs_utf8_wrap(sys.stdout):
try:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
except (ValueError, OSError):
pass
if _needs_utf8_wrap(sys.stderr):
try:
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
except (ValueError, OSError):
pass
# ── Defang importlib.metadata.packages_distributions() before transformers ──
# transformers/utils/import_utils.py:45 calls
# PACKAGE_DISTRIBUTION_MAPPING = importlib.metadata.packages_distributions()
# at module-import time, with NO guard. The function walks every
# *.dist-info/METADATA in site-packages and accesses
# `dist.metadata['Name']`. ANY corrupt dist-info (METADATA missing,
# Name: header malformed, or pip rewriting it concurrently) raises
# KeyError('Name') → transformers crashes its import → langchain →
# hart_intelligence_entry crashes → Tier-1 HARTOS init dies → agent
# daemon never starts → admin Agent Dashboard stays empty for the rest
# of the process lifetime (Tier-1 init is one-shot).
#
# Regression observed 2026-04-26 23:05:23 in dev .venv (transient
# corrupt dist-info). The fix monkey-patches the stdlib function with
# a try/except wrapper so a single bad dist-info no longer cascades to
# a full HARTOS Tier-1 outage. Running BEFORE any langchain /
# transformers import in this module guarantees transformers picks up
# the wrapped version.
import importlib.metadata as _md_safe
_orig_pd = getattr(_md_safe, 'packages_distributions', None)
if _orig_pd is not None and not getattr(_orig_pd, '_hartos_guarded', False):
def _safe_packages_distributions():
try:
return _orig_pd()
except Exception:
# Best-effort fallback: empty mapping. Auto-docstring
# lookups against {} return generic guesses (worse but
# nonfatal).
return {}
_safe_packages_distributions._hartos_guarded = True
_md_safe.packages_distributions = _safe_packages_distributions
del _md_safe
# ── Defang transformers `_LazyModule.__getattr__` recursion ──
# transformers ships a `_LazyModule` class whose `__getattr__` does
# ``hasattr(self, candidate_name)`` which itself triggers
# ``__getattr__`` again on the not-yet-bound attribute → ~1500-frame
# recursion that holds the `transformers` per-package import lock for
# minutes on cold disk. Triggered by ANY downstream
# ``from transformers import GPT2TokenizerFast`` (langchain_core does
# this transitively). Live thread dump 2026-04-28 22:18 captured the
# hartos-init thread in 28+ frames of this recursion, blocking every
# concurrent caller of ``get_world_model_bridge`` (15 dashboard
# requests + peer_discovery + telemetry) for the rest of the process
# lifetime.
#
# Fix: import `GPT2TokenizerFast` from its underlying submodule and
# write it to ``transformers.__dict__`` BEFORE any lazy lookup fires.
# Once the attribute is bound, `hasattr()` short-circuits at the dict
# and never calls `__getattr__`. Every downstream caller gets the
# bound class via plain dict lookup, no recursion.
#
# This used to live in Nunba's `app.py::_prewarm_hartos_chain` (commit
# c0894a2a, 2026-04-28). That prewarm was deleted in favour of the
# `hartos_bootstrap` facade — but the direct-bind got deleted with it,
# letting the recursion regress. The right home for this is HARTOS,
# not Nunba: it must run before HARTOS's own
# `from langchain_classic.llms import OpenAI` (next stmt below) AND
# before any consumer's deferred bootstrap touches transformers.
try:
import transformers as _tf_safe
from transformers.models.gpt2.tokenization_gpt2_fast import (
GPT2TokenizerFast as _GPT2TokenizerFast_direct,
)
if not hasattr(_tf_safe, '__dict__') or \
'GPT2TokenizerFast' not in _tf_safe.__dict__:
_tf_safe.__dict__['GPT2TokenizerFast'] = _GPT2TokenizerFast_direct
del _tf_safe, _GPT2TokenizerFast_direct
except Exception:
# If transformers isn't installed or moved the symbol, fall through.
# Worker threads will hit the lazy path and pay the recursion once;
# bad but not fatal (and surfaced via the hartos_init_error.log).
import logging as _early_logging
_early_logging.getLogger(__name__).warning(
"transformers GPT2 tokenizer defang skipped (symbol absent/moved)", exc_info=True)
# ── Defang transformers `_LazyModule.__getattr__` re-entry recursion ──
#
# Symptom: Tier-1 init thread spends minutes (and burns the GIL the
# whole time, starving Hypercorn workers) inside
# ``transformers.utils.import_utils.py:2215`` —
#
# transformers_module = sys.modules.get("transformers")
# if transformers_module and hasattr(transformers_module, candidate_name):
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#
# That ``hasattr(transformers_module, candidate_name)`` triggers
# ``transformers.__getattr__(candidate_name)`` (because transformers'
# top-level module object IS a ``_LazyModule`` with a custom
# ``__getattr__``), which hits the SAME ``hasattr`` line on a different
# candidate, which recurses again. 130+ frames deep, all
# ``active+gil``, no other thread can run. The recursion is
# bounded (SLOW_TO_FAST_CONVERTERS is finite) so ``RecursionError``
# never fires — it just burns CPU forever on a graph walk that should
# be a single dict lookup.
#
# Why the GPT2TokenizerFast bind above isn't enough: that fix only
# covers ONE leaf of the lazy graph. Whichever ``*TokenizerFast``
# is requested first triggers the same recursion via different
# candidate paths.
#
# Architectural note (impact-assessed): we considered three other
# approaches and rejected each — see
# ``memory/feedback_langchain_uplift_path.md``:
# 1. ``sys.setrecursionlimit(N)`` — recursion is bounded, never
# raises; forcing N too low breaks legitimate deep stacks.
# 2. Lazy-import langchain in HARTOS — multi-week refactor; AND
# HevolveAI (encrypted .so.enc, NOT refactor-able) is a second
# transformers consumer that would still trigger this recursion
# at runtime, so the layer-A fix doesn't cover all consumers.
# 3. Uplift langchain-classic → langchain-core + LangGraph — same
# multi-week scope as (2), still doesn't cover HevolveAI.
#
# (1)–(3) are all consumer-side fixes. This patch is at the
# ``transformers._LazyModule`` layer, which means BOTH consumers
# (open langchain + closed-source HevolveAI) benefit without changes
# to either. That's why a third-party monkey-patch is the
# architecturally correct answer here, not a band-aid.
#
# Mechanism: install a re-entry guard on ``_LazyModule.__getattr__``.
# When a thread is already mid-``__getattr__`` for a (module, name)
# pair and the same pair is requested again (which is what
# ``hasattr`` does inside the same method), raise ``AttributeError``
# immediately. ``hasattr`` swallows ``AttributeError`` and returns
# ``False`` — semantically identical to "the attribute hasn't been
# bound yet" which is exactly what the calling code is asking.
#
# Cross-name recursion (``__getattr__('A')`` triggering
# ``__getattr__('B')``) is preserved — only same-name re-entry on
# the same thread is short-circuited. That's the precise shape of
# the bug.
#
# TODO(upstream): file an issue with HuggingFace transformers
# proposing the fix at the source — replace the offending
# ``hasattr(transformers_module, candidate_name)`` with
# ``candidate_name in transformers_module.__dict__``. Once a
# released transformers carries the upstream fix, this monkey-patch
# becomes vestigial and can be removed. Track via
# ``memory/feedback_transformers_lazy_module_patch.md``.
try:
import threading as _tf_threading
from transformers.utils import import_utils as _tf_import_utils
_tf_lazy_module_class = getattr(_tf_import_utils, '_LazyModule', None)
if _tf_lazy_module_class is not None and not getattr(
_tf_lazy_module_class, '_hartos_reentry_guarded', False):
_orig_getattr = _tf_lazy_module_class.__getattr__
_resolving = _tf_threading.local()
# Bind both the original method AND the threading.local() store
# via default arguments so the closure carries its own
# references. Module-body cleanup (`del _orig_getattr` etc.)
# below is then safe — without this, Python's closure-by-name
# lookup would NameError when the lazy module later actually
# invokes __getattr__ at runtime (symptom:
# ``Tier-1 FAILED — name '_orig_getattr' is not defined``).
def _hartos_reentry_guarded_getattr(
self, name, _orig=_orig_getattr, _local=_resolving):
in_progress = getattr(_local, 'set', None)
if in_progress is None:
in_progress = set()
_local.set = in_progress
key = (id(self), name)
if key in in_progress:
# Same (module, name) re-entry on this thread —
# ``hasattr`` probe inside ``__getattr__``. The
# caller is asking "is this already bound?"; we are
# mid-resolution so the answer is "not yet". Raising
# AttributeError tells hasattr→False, which is the
# semantic the caller wanted.
raise AttributeError(
f"module {self.__name__!r} has no attribute {name!r} "
f"(HARTOS re-entry guard: same-name __getattr__ "
f"recursion broken)"
)
in_progress.add(key)
try:
return _orig(self, name)
finally:
in_progress.discard(key)
_tf_lazy_module_class.__getattr__ = _hartos_reentry_guarded_getattr
_tf_lazy_module_class._hartos_reentry_guarded = True
# NOTE: not deleting _orig_getattr / _resolving — the patched
# method's default args already hold references; deleting the
# module-scope names would leave them dangling at module-body cleanup
# but otherwise harmless. Leave them visible (underscore-prefixed,
# excluded from `from … import *` semantics) to keep the call site
# debuggable via dir().
del _tf_threading, _tf_import_utils, _tf_lazy_module_class
except Exception:
# transformers not installed, version moved _LazyModule, or some
# other surprise — fall through. We've still got the
# GPT2TokenizerFast direct-bind above; recursion may resurface
# but won't crash boot.
import logging as _early_logging
_early_logging.getLogger(__name__).warning(
"transformers _LazyModule re-entry defang skipped (not installed/moved)", exc_info=True)
from bs4 import BeautifulSoup
from enum import Enum
from hartos.cultural_wisdom import get_cultural_prompt_compact
from hartos.agent_identity import build_identity_prompt, SECRETS_GUARDRAIL, extract_owner_name
# The one description of an agent's credential ask (core.agent_tools shares it).
from hartos.ai_key_vault import REQUEST_RESOURCE_DESCRIPTION
# langchain_classic — pydantic v2-compatible fork of langchain 0.0.230.
# Fail-safe (#99): in a frozen env where langchain_classic is absent (the HART OS
# ISO's pinned June-2025 nixpkgs predates the split package), import the module
# ANYWAY so the backend serves its non-langchain surface (social / sync / status /
# daemon) instead of being dead. The langchain chat path then errors at call-time
# (gated by _LANGCHAIN_OK), not at import. The try-block IS the original import
# list, so any build that HAS langchain_classic is byte-for-byte unchanged — this
# can only help the no-langchain build, never regress the working one. The real
# fix (so chat actually works on the ISO) is still to package langchain_classic
# into the Nix env or bump the pin; this just stops a missing dep from killing the
# whole backend.
try:
from langchain_classic.llms import OpenAI
from langchain_classic.chains import LLMChain
from langchain_classic.prompts import PromptTemplate
from langchain_classic.agents import (
ZeroShotAgent, Tool, AgentExecutor, ConversationalAgent,
ConversationalChatAgent, LLMSingleActionAgent, AgentOutputParser,
load_tools, initialize_agent, AgentType
)
from langchain_classic.prompts import (
ChatPromptTemplate, MessagesPlaceholder,
SystemMessagePromptTemplate, HumanMessagePromptTemplate
)
from langchain_classic.chains import LLMMathChain
from langchain_classic.chains.conversation.memory import ConversationSummaryMemory, ConversationBufferWindowMemory
from langchain_classic.chat_models import ChatOpenAI
from langchain_classic.llms.base import LLM
from langchain_classic.memory import ConversationBufferMemory, ReadOnlySharedMemory
from langchain_classic.schema import AgentAction, AgentFinish, OutputParserException, HumanMessage, AIMessage, SystemMessage
from langchain_classic.tools import OpenAPISpec, APIOperation, StructuredTool
from langchain_classic.utilities import GoogleSearchAPIWrapper
_LANGCHAIN_OK = True
except ImportError as _lc_err:
import logging as _logging
_logging.getLogger(__name__).error(
"langchain_classic unavailable (%s) — backend boots WITHOUT the langchain "
"chat path; social/sync/status/daemon still serve (#99).", _lc_err,
exc_info=True)
# 2026-08-24: the message alone cannot be root-caused — the frozen
# install failed with "No module named 'yaml.error'" while every yaml
# copy on every path (frozen lib/, python-embed site-packages, user
# site, ~/.nunba) had a complete package incl. error.py, and the same
# import block passed on bare python-embed. Whatever yaml object the
# app actually resolved is the evidence; capture it.
try:
import sys as _lc_sys
_lc_y = _lc_sys.modules.get('yaml')
_logging.getLogger(__name__).error(
"langchain import-failure context: yaml=%r file=%s path=%s "
"spec=%s | sys.path[:6]=%s",
_lc_y, getattr(_lc_y, '__file__', None),
getattr(_lc_y, '__path__', None),
getattr(_lc_y, '__spec__', None), _lc_sys.path[:6])
except Exception:
pass
_LANGCHAIN_OK = False
class _LangchainUnavailable:
"""A langchain symbol used at call-time raises clearly (not a bare
NameError). The four module-level `class X()` subclasses below
inherit plain ``object`` so the module still imports."""
def __init__(self, *a, **k):
raise RuntimeError("langchain_classic is not installed in this build")
def __call__(self, *a, **k):
raise RuntimeError("langchain_classic is not installed in this build")
# Base classes subclassed at module level (ChatQwen3VL/CustomGPT -> LLM,
# CustomAgentExecutor -> AgentExecutor, CustomConvoOutputParser ->
# AgentOutputParser) MUST be real, plain classes so the defs import.
LLM = AgentExecutor = AgentOutputParser = object
# Everything else: a sentinel that fails loudly only if actually used.
OpenAI = LLMChain = PromptTemplate = ZeroShotAgent = Tool = \
ConversationalAgent = ConversationalChatAgent = LLMSingleActionAgent = \
load_tools = initialize_agent = AgentType = ChatPromptTemplate = \
MessagesPlaceholder = SystemMessagePromptTemplate = \
HumanMessagePromptTemplate = LLMMathChain = ConversationSummaryMemory = \
ConversationBufferWindowMemory = ChatOpenAI = ConversationBufferMemory = \
ReadOnlySharedMemory = AgentAction = AgentFinish = OutputParserException = \
HumanMessage = AIMessage = SystemMessage = OpenAPISpec = APIOperation = \
StructuredTool = GoogleSearchAPIWrapper = _LangchainUnavailable
import time
# ChatGroq - optional import (version compatibility issues)
try:
from langchain_groq import ChatGroq
except Exception:
ChatGroq = None
try:
from langchain_classic.requests import Requests
except (ImportError, AttributeError):
Requests = None
from flask import Flask, jsonify, request, g
from functools import wraps
import json
import os
import re
import secrets
import logging
import threading
import atexit
import requests
import pytz
import hashlib
from security.node_integrity import compute_code_hash, compute_file_manifest, verify_json_signature
from security import master_key
from core.platform_paths import get_coding_workspace_dir
# Module logger. 13 sites already call `logger.` / `klogger.` and NOTHING
# bound either name at module scope -- ruff F821 reports them, and 12 of the 13
# sit inside `except` handlers, so the NameError replaced the real diagnosis.
# Traced: an exception in _wire_qr_pair_emitter hits `logger.debug` (:3520) ->
# NameError -> caught at :3525 -> NameError -> caught at :4165 -> surfaces to
# the user as "Channel connect error: name 'logger' is not defined". In
# _init_skills and _init_runtime_tools (thread entry points) it kills the
# thread silently. Spelled the way the other ~40 sites in this file spell it;
# _validate_startup's own local binding still wins inside that function.
logger = logging.getLogger(__name__)
# --- Hevolve Boot Integrity Verification ---
_boot_logger = logging.getLogger("hevolve_integrity")
def hevolve_verify_boot():
mode = (os.getenv("HEVOLVE_ENFORCEMENT_MODE") or "warn").lower()
tier = (os.getenv("HEVOLVE_NODE_TIER") or "unknown").lower()
manifest_path = master_key.RELEASE_MANIFEST_FILENAME
if not os.path.exists(manifest_path):
msg = f"[HevolveIntegrity] release_manifest.json missing (tier={tier}, mode={mode})"
if mode == "hard":
raise RuntimeError(msg)
_boot_logger.warning(msg)
return
with open(manifest_path) as f:
d = json.load(f)
pub_hex = d.get("master_public_key")
sig_hex = d.get("master_signature")
payload_obj = {k: d[k] for k in d.keys() if k != "master_signature"}
payload_json = json.dumps(payload_obj, sort_keys=True, separators=(",", ":"))
try:
verify_json_signature(pub_hex, payload_json, sig_hex)
except Exception as e:
msg = f"[HevolveIntegrity] Signature verification failed: {e}"
if mode == "hard":
raise RuntimeError(msg)
_boot_logger.warning(msg)
return
# force_walk: the boot check must hash the bytes it is about to run, never
# a cache. compute_code_hash's mtime cache lives in agent_data/, which the
# deploy bind-mounts into EVERY container, so the cache one image writes is
# read by the next. Measured 2026-09-22 15:56Z on central: a cancelled
# deploy's script kept running and booted its image at 15:37Z, writing the
# cache; the next image's checkout carried an earlier mtime (15:32Z, its
# git reset), so this line returned the PREVIOUS image's hash, called the
# new image tampered ("CODE_HASH mismatch"), and the deploy rolled back
# into a crash loop. A forced walk is a pure read: one directory walk.
current_code_hash = compute_code_hash(force_walk=True)
if d.get("code_hash") != current_code_hash:
msg = "[HevolveIntegrity] CODE_HASH mismatch"
if mode == "hard":
raise RuntimeError(msg)
_boot_logger.warning(msg)
fm = compute_file_manifest()
current_fm_hash = hashlib.sha256(json.dumps(fm, sort_keys=True).encode()).hexdigest()
if d.get("file_manifest_hash") != current_fm_hash:
msg = "[HevolveIntegrity] FILE_MANIFEST_HASH mismatch"
if mode == "hard":
raise RuntimeError(msg)
_boot_logger.warning(msg)
_boot_logger.info(f"[HevolveIntegrity] Boot verification OK (tier={tier}, mode={mode})")
# Defer boot verification to main() — do not run at import time
# --- End Boot Integrity Verification ---
from core.http_pool import LLM_COMPLETION_TIMEOUT, pooled_get, pooled_post
from core.auth_local import (
_is_local_request, require_local_or_token, require_local_or_token_csrf_safe,
)
from datetime import datetime, timezone
from typing import List, Union, Optional, Mapping, Any, Dict
# Conversational chat imports
try:
from langchain_classic.agents.conversational_chat.output_parser import ConvoOutputParser
from langchain_classic.agents.conversational_chat.prompt import FORMAT_INSTRUCTIONS
from langchain_classic.output_parsers.json import parse_json_markdown
except (ImportError, AttributeError):
ConvoOutputParser = None
FORMAT_INSTRUCTIONS = None
parse_json_markdown = None
# Tools imports
try:
from langchain_classic.tools.requests.tool import RequestsGetTool
except (ImportError, AttributeError):
RequestsGetTool = None
try:
from langchain_classic.utilities.requests import TextRequestsWrapper
except (ImportError, AttributeError):
TextRequestsWrapper = None
try:
import tiktoken
except ImportError:
tiktoken = None
from pytz import timezone
from datetime import datetime, timedelta
from waitress import serve
from logging.handlers import RotatingFileHandler
# Pydantic v2 imports (we have pydantic 2.12.5)
try:
from pydantic import BaseModel, Field, field_validator as root_validator
except ImportError:
from pydantic import BaseModel, Field, root_validator
from hartos.threadlocal import thread_local_data
# Crossbar HTTP publisher — canonical path is `crossbarhttp3.CrossbarHttpPublisher`
# (the same publisher used by integrations/social/realtime.py:25). We fall
# back to the legacy `crossbarhttp.Client` API only if the canonical
# publisher is not installed. Either path is OPTIONAL — if neither is
# importable the module-load must NOT crash; this layer is dead-code in
# flat mode where MessageBus uses LOCAL EventBus + PeerLink without HTTP.
#
# Regression 2026-04-26: a `.venv` with broken `crossbarhttp` (no
# __init__.py, Client at .crossbarhttp.Client) made the previous
# `crossbarhttp.Client(...)` module-level call raise AttributeError,
# crashing the whole HARTOS import (Tier-1 dead → agent daemon dead →
# admin dashboard empty). Wrapping in try/except + using the canonical
# crossbarhttp3 first kills that failure mode.
_crossbar_publisher = None
try:
from crossbarhttp3 import CrossbarHttpPublisher as _CrossbarPub
except Exception:
_CrossbarPub = None
try:
import crossbarhttp as _legacy_cb # may be a broken stub
except Exception:
_legacy_cb = None
from PIL import Image
import numpy as np
# Cohere rerank - make optional to avoid pydantic v2 incompatibility with old langchain
try:
from langchain_community.retrievers.document_compressors import cohere_rerank
except Exception:
cohere_rerank = None # Not available
import asyncio
import aiohttp
import redis
import pickle
from threading import Thread
from concurrent.futures import ThreadPoolExecutor
from dotenv import load_dotenv
try:
import autogen
except ImportError:
autogen = None # Optional dependency
from typing import Dict, Tuple
load_dotenv()
#autogen requirements
try:
from hartos.create_recipe import recipe, time_based_execution as time_execution, visual_execution
from hartos.reuse_recipe import chat_agent, crossbar_multiagent, time_based_execution, visual_based_execution
except ImportError as e:
# print() went nowhere: frozen windowed builds have no real stdout, so the
# ONE line explaining why agent creation is dead was invisible. MEASURED
# 2026-08-19: "Could not import recipe modules" appears 0 times across
# langchain.log/gui_app.log/server.log while recipe=None caused 54
# `TypeError: 'NoneType' object is not callable` at the two unguarded call
# sites (:9351, :9578), each one a 500 on /chat. Same logger idiom this
# file already uses at line 261; exc_info keeps the real ImportError cause.
logging.getLogger(__name__).error(
"Could not import recipe modules -- agent creation and recipe "
"execution are DISABLED for this process: %s", e, exc_info=True)
recipe = None
time_execution = None
visual_execution = None
chat_agent = None
crossbar_multiagent = None
time_based_execution = None
visual_based_execution = None
try:
from autobahn.asyncio.component import Component, run
except ImportError:
Component = None
run = None
import threading
try:
from hartos.helper import retrieve_json, PROMPTS_DIR, safe_prompt_path, _is_terminate_msg
except Exception:
retrieve_json = None
safe_prompt_path = None
# helper failed to import — resolve PROMPTS_DIR via the SAME canonical resolver
# helper uses, so we NEVER fall back to the read-only /nix/store package dir on
# the embedded OS (the boot crash this guards). get_recipe_prompts_dir handles
# frozen desktop + embedded HART OS (/nix/store or /etc/hartos-release) + dev.
from core.platform_paths import get_recipe_prompts_dir
PROMPTS_DIR = os.path.abspath(get_recipe_prompts_dir())
# Ensure prompts directory exists (agent creation writes JSON here)
os.makedirs(PROMPTS_DIR, exist_ok=True)
# Boot-time prompts/ snapshot (best-effort, non-blocking). Bounds
# data loss to "since last reboot" if the user wipes data dir or
# hits a corruption. Fires at module-import time so it runs in
# BOTH the CLI hevolve-server path AND the Nunba bundled path
# (Nunba imports hart_intelligence_entry as a library; the CLI
# main() is never called in that case). See core/prompts_backup.py.
try:
from core.prompts_backup import snapshot_at_boot as _hartos_snapshot_at_boot
_hartos_snapshot_at_boot()
except Exception as _snap_err:
logging.getLogger('hevolve_core').debug(
f'prompts_backup at module-load skipped: {_snap_err}')
# Once, after the snapshot above: move aside the VLM learnings that do not say
# which action they ran under (a next-free-slot walker filed them under other
# actions' ids; the loader refuses them either way). Moved, never deleted;
# marker-guarded. See hartos.helper.quarantine_unproven_vlm_learnings_once.
try:
from hartos.helper import quarantine_unproven_vlm_learnings_once as _vlm_q_once
_vlm_q_once(PROMPTS_DIR)
except Exception as _vlm_q_err:
logging.getLogger('hevolve_core').warning(
f'VLM learning quarantine at module-load skipped: {_vlm_q_err}')
# Google A2A integration (from gpt4.1)
try:
from integrations.google_a2a import initialize_a2a_server, get_a2a_server, register_all_agents
except ImportError:
initialize_a2a_server = None
get_a2a_server = None
register_all_agents = None
# os.environ['LANGCHAIN_TRACING_V2'] = 'true'
# os.environ['LANGCHAIN_ENDPOINT'] = 'https://api.smith.langchain.com'
# os.environ['LANGCHAIN_API_KEY'] = os.getenv("LANGCHAIN_API_KEY")
# os.environ["LANGCHAIN_PROJECT"] = os.getenv("LANGCHAIN_PROJECT")
groq_api_key = os.environ.get('GROQ_API_KEY', '')
# ============================================================================
# MemoryGraph — Framework-agnostic provenance-aware memory layer
# ============================================================================
_memory_graphs = {} # Cache: "user_id_prompt_id" → MemoryGraph instance
_memory_graph_lock = threading.Lock()
def _get_or_create_graph(user_id, prompt_id=None):
"""Get or create a MemoryGraph instance for a user/prompt session."""
try:
from integrations.channels.memory.memory_graph import MemoryGraph
session_key = f"{user_id}_{prompt_id}" if prompt_id else str(user_id)
with _memory_graph_lock:
if session_key not in _memory_graphs:
from core.platform_paths import get_memory_graph_dir
db_path = get_memory_graph_dir(session_key)
_memory_graphs[session_key] = MemoryGraph(
db_path=db_path,
user_id=str(user_id),
)
return _memory_graphs[session_key]
except Exception as e:
logging.getLogger(__name__).debug(f"MemoryGraph init skipped: {e}")
return None
def _record_lifecycle(status, user_id, prompt_id, details=''):
"""Record agent lifecycle event in MemoryGraph (fire-and-forget, zero latency)."""
def _bg():
try:
graph = _get_or_create_graph(user_id, prompt_id)
if graph:
graph.register_lifecycle(
event=status,
agent_id=str(user_id),
session_id=f"{user_id}_{prompt_id}",
details=details,
)
except Exception:
logging.getLogger(__name__).exception("_bg: swallowed Exception")
threading.Thread(target=_bg, daemon=True).start()
# ============================================================================
# Custom Qwen3-VL LangChain Wrapper
# ============================================================================
class ChatQwen3VL(LLM):
"""
LangChain LLM wrapper over the ONE configured LLM (an OpenAI-compatible
chat/completions endpoint: the configured API, or the local llama-server
when none is configured; see core.autogen_config).
Posts through _pooled_post_with_refusal_check like every other
chat/completions caller in this module, so it shares the refusal guard,
the completion timeout and the auth header. base_url / model_name are
left None to follow GPT_API / LLM_MODEL_NAME; set them only for an
explicit, deliberate override.
"""
base_url: str = None # None: GPT_API (the configured backend)
model_name: str = None # None: LLM_MODEL_NAME
temperature: float = 0.7
max_tokens: int = 1500
@property
def _llm_type(self) -> str:
return "qwen3.5"
def _call(self, prompt: str, stop: list = None) -> str:
"""
Call the Qwen3.5 API with the given prompt.
Args:
prompt: The input text prompt
stop: Optional stop sequences
Returns:
The generated response text
"""
_url = f"{self.base_url}/chat/completions" if self.base_url else GPT_API
payload = {
"model": self.model_name or LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"temperature": self.temperature,
"max_tokens": self.max_tokens
}
if stop:
payload["stop"] = stop
_log = logging.getLogger(__name__)
# Route through _pooled_post_with_refusal_check (same helper CustomGPT
# uses) so this langchain wrapper gets the SAME refusal-override guard;
# previously only CustomGPT had it, so a draft refusal from THIS wrapper
# flowed straight to the user (the drifted parallel path).
try:
response = _pooled_post_with_refusal_check(
_url,
json=payload,
timeout=60
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except Exception as e:
_log.warning(f"[LLM] {_url} unavailable: {e}")
# Local kind only: re-resolve the llama URL (port conflicts, warm
# starts). With a configured API there is nothing else to try: the
# single API is the only LLM this node talks to, never a local
# fallback behind the user's back.
if LLM_KIND != 'local':
raise
from core.port_registry import get_local_llm_url
_llm_url = get_local_llm_url().rstrip('/') + '/chat/completions'
if _llm_url != _url:
try:
_log.info(f"[LocalLLM] Falling back to llama.cpp at {_llm_url}")
response = _pooled_post_with_refusal_check(
_llm_url,
json=payload,
timeout=120
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except Exception as e2:
_log.error(f"[LocalLLM] llama.cpp fallback also failed: {e2}")
raise
raise
@property
def _identifying_params(self) -> dict:
"""Return identifying parameters."""
return {
"model_name": self.model_name,
"base_url": self.base_url,
"temperature": self.temperature,
"max_tokens": self.max_tokens
}
def get_llm(temperature=0.7, max_tokens=1500):
"""
Get LLM instance based on configuration.
Priority:
1. Wizard-configured cloud provider (HEVOLVE_ACTIVE_CLOUD_PROVIDER env var)
through its vendor SDK. The model is the one the vault exported; there
is no vendor default to fall back on.
2. ChatQwen3VL over the ONE configured backend (GPT_API / LLM_MODEL_NAME).
"""
_active = os.environ.get('HEVOLVE_ACTIVE_CLOUD_PROVIDER', '')
if _active == 'anthropic' and os.environ.get('ANTHROPIC_API_KEY'):
try:
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
model=os.environ.get('ANTHROPIC_MODEL') or LLM_MODEL_NAME,
api_key=os.environ['ANTHROPIC_API_KEY'],
temperature=temperature,
max_tokens=max_tokens,
)
except ImportError:
logging.getLogger(__name__).debug("get_llm: swallowed ImportError")
if _active == 'google_gemini' and os.environ.get('GOOGLE_API_KEY'):
try:
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=os.environ.get('GOOGLE_MODEL') or LLM_MODEL_NAME,
google_api_key=os.environ['GOOGLE_API_KEY'],
temperature=temperature,
max_output_tokens=max_tokens,
)
except ImportError:
logging.getLogger(__name__).debug("get_llm: swallowed ImportError")
if _active == 'groq' and os.environ.get('GROQ_API_KEY'):
try:
from langchain_groq import ChatGroq
return ChatGroq(
model=os.environ.get('GROQ_MODEL') or LLM_MODEL_NAME,
api_key=os.environ['GROQ_API_KEY'],
temperature=temperature,
max_tokens=max_tokens,
)
except ImportError:
logging.getLogger(__name__).debug("get_llm: swallowed ImportError")
if _active in ('openai', 'azure_openai', 'custom_openai') and os.environ.get('OPENAI_API_KEY'):
_kwargs = dict(
model_name=os.environ.get('OPENAI_MODEL') or LLM_MODEL_NAME,
temperature=temperature,
max_tokens=max_tokens,
)
if _active == 'custom_openai':
# The endpoint the vault exported, the same one autogen uses.
_kwargs['openai_api_base'] = _llm_entry.get('base_url') or os.environ.get('CUSTOM_LLM_BASE_URL', '')
return ChatOpenAI(**_kwargs)
return ChatQwen3VL(temperature=temperature, max_tokens=max_tokens)
# ============================================================================
class RequestLogRecord(logging.LogRecord):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Safely get the req_id from thread-local storage
self.req_id = thread_local_data.get_request_id()
# logging info
# Use the custom log record factory
logging.setLogRecordFactory(RequestLogRecord)
# In bundled/pip-installed mode (NUNBA_BUNDLED env set by main.py), redirect logs
# to the shared Nunba log directory; standalone keeps default behavior.
_is_bundled = bool(os.environ.get('NUNBA_BUNDLED') or getattr(sys, 'frozen', False))
if _is_bundled:
from core.platform_paths import get_log_dir as _get_log_dir_lc
_nunba_log_dir = _get_log_dir_lc()
try:
os.makedirs(_nunba_log_dir, exist_ok=True)
except PermissionError:
_nunba_log_dir = os.path.join(os.path.expanduser('~'), '.nunba', 'logs')
os.makedirs(_nunba_log_dir, exist_ok=True)
_langchain_log_path = os.path.join(_nunba_log_dir, 'langchain.log')
else:
# Non-bundled: write to the canonical WRITABLE data dir, NEVER the CWD — on the
# embedded OS the CWD is the read-only /nix/store package dir, so a relative
# 'langchain.log' crashes boot with OSError [Errno 30] Read-only file system.
try:
from core.platform_paths import get_data_dir
_ld = os.path.join(get_data_dir(), 'logs')
os.makedirs(_ld, exist_ok=True)
_langchain_log_path = os.path.join(_ld, 'langchain.log')
except Exception:
_langchain_log_path = os.devnull # last resort: a log path must never brick boot
handler = RotatingFileHandler(_langchain_log_path, maxBytes=5_000_000, backupCount=2, encoding='utf-8')
handler.setLevel(logging.INFO)
# Prevent UnicodeEncodeError on Windows (cp1252) when LLM output contains emoji.
# In cx_Freeze frozen builds, stdout/stderr may be closed — redirect to devnull.
try:
if sys.stdout is None or sys.stdout.closed:
sys.stdout = open(os.devnull, 'w', encoding='utf-8', errors='replace')
else:
sys.stdout.reconfigure(errors='replace')
except Exception:
sys.stdout = open(os.devnull, 'w', encoding='utf-8', errors='replace')
try:
if sys.stderr is None or sys.stderr.closed:
sys.stderr = open(os.devnull, 'w', encoding='utf-8', errors='replace')
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception")
stream_handler = logging.StreamHandler(sys.stdout)
# Create a logging format.
# [%(threadName)s] is included so EVERY line says which thread it ran on —
# without it, interleaved background threads (agent_daemon,
# resource_governor_monitor, compute_optimizer, TTS _bg, hevolveai-supervisor)
# all log as "RequestID: None" and are indistinguishable from the chat
# request thread, making per-thread latency attribution undecidable (the
# 2026-05-31 "why is hi slow" mis-attribution: a 17s gap was blamed on the
# wrong subsystem purely from log adjacency). threadName is a built-in
# LogRecord attribute (always present) so this is free + safe.
formatter = logging.Formatter(
'%(asctime)s - %(name)s- [RequestID: %(req_id)s] [%(threadName)s] - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
stream_handler.setFormatter(formatter)
# Configure root logger: drop OUR OWN previously-installed handlers to prevent
# duplicates on re-import, then attach them once. In bundled mode the root
# logger gets both stream + file so that ALL module loggers are captured. In
# standalone mode only Flask's app.logger gets the handlers.
#
# NEVER call `_root.handlers.clear()` here. This module runs at IMPORT time,
# and when a host application has already configured root logging, a blanket
# clear() silently steals it.
#
# Incident 2026-08-03 (task #489): Nunba attaches gui_app.log + server.log to
# the ROOT logger at startup. Nunba imports hart_intelligence_entry lazily,
# ~140s into every run — at which point this block detached both handlers.
# Measured on a live frozen build: gui_app.log and server.log both stopped
# dead at boot+140s and stayed silent for the next 134 minutes of uptime,
# while named-logger files (agent_system.log) kept writing. Every Nunba root
# log line was silently relocated into langchain.log. The two files CLAUDE.md
# documents as THE diagnostic logs were dark for ~98% of each session, which
# is why several investigations "found nothing in the logs".
#
# Tagging our handlers keeps the original de-duplication intent (a second
# import removes exactly what the first import added) without touching
# handlers owned by anyone else.
_HARTOS_HANDLER_TAG = '_hartos_root_handler'
def _install_root_handlers(root, bundled, file_handler, console_handler):
"""Attach this module's handlers to the ROOT logger, without stealing anyone's.
Three launch shapes, three answers:
* bundled (cx_Freeze): root gets file + console, so every module logger
lands in langchain.log and the console;
* a host that already configured root (Nunba's gui_app.log/server.log,
pytest's capture): root is left exactly as found, per the 2026-08-03
incident above;
* a bare root with NO handler at all: root gets the console handler.
The third case is the OS and Docker launch (`python hart_intelligence_entry.py`
under systemd). Before 2026-09-24 it got nothing, so every INFO line the
root logger saw during main() went to Python's lastResort handler, which
prints WARNING and above only. Measured on the hart-ota-central nixos test
(2026-09-23 run, shard 0): "STARTUP VALIDATION WARNINGS" reached the journal
as bare text, and "[Guardrail] hash verified", "Platform bootstrapped" and
"Local subscribers bootstrapped: ... ota-push" never appeared at all, until a
background thread's import called logging.basicConfig() some two seconds
after Waitress bound and INFO started flowing in basicConfig's format. The
OTA test waited 240 s for a line the process had already dropped, and the
boot narrative that hart-backend.nix exempts from the journald rate limit
was never being written in the first place.
Attaching only when root is empty is the same rule basicConfig() follows, so
a later basicConfig() call becomes the no-op it was always meant to be and
the journal keeps ONE format (the RequestID/threadName one) end to end.
"""
for existing in list(root.handlers):
if getattr(existing, _HARTOS_HANDLER_TAG, False):
root.removeHandler(existing)
root.setLevel(logging.INFO)
if bundled:
setattr(file_handler, _HARTOS_HANDLER_TAG, True)
setattr(console_handler, _HARTOS_HANDLER_TAG, True)
root.addHandler(file_handler)
root.addHandler(console_handler)
else:
# The bare-root case is ONE rule shared with the agent daemon's
# entrypoint (core.log_handlers); it attaches `console_handler`
# only when root has no handler at all, tagged with the same tag.
from core.log_handlers import install_console_handler_if_bare
install_console_handler_if_bare(root, handler=console_handler,
tag=_HARTOS_HANDLER_TAG)
_root = logging.getLogger()
_install_root_handlers(_root, _is_bundled, handler, stream_handler)
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY') or secrets.token_hex(32)
# One payload policy for app AND transport: core.serve passes the same
# constant to Hypercorn's WSGI middleware (whose 64 KB library default
# otherwise transport-rejects bodies Flask's own cap here would accept).
from core.constants import (
AGENT_WORK_TOOL_NAMES, AGENTIC_ROUTER_TOOL_NAME, CREATE_AGENT_TOOL_NAME,
MAX_PAYLOAD_BYTES, SHELL_COMMAND_TIMEOUT_S,
)
app.config['MAX_CONTENT_LENGTH'] = MAX_PAYLOAD_BYTES # 2MB default, HEVOLVE_MAX_PAYLOAD_BYTES to override
# ...for a remote caller. A local caller's body has no cap (owner
# 2026-10-10); the per-request rule is core.serve.max_body_size.
from core.serve import caller_capped_request
app.request_class = caller_capped_request(app.request_class)
# ── LLM outbound logger ───────────────────────────────────────────────
# Monkey-patches httpx.Client.send (+ AsyncClient) to (a) inject the
# thread-local HARTOS request_id as the OpenAI ``user`` field on every
# POST to :8082/v1/chat/completions and (b) append the full request
# body to ~/Documents/Nunba/logs/llm_outbound.jsonl. Covers autogen,
# langchain, openai-python — every Python-originated LLM call funnels
# through httpx. See ``core/llm_outbound_logger.py`` for the full
# rationale (the 2026-05-12 IPL ctx-overflow incident motivated this).
try:
from core.llm_outbound_logger import install as _install_outbound_hook
_install_outbound_hook()
except Exception as _outbound_err:
logging.getLogger(__name__).debug(
"llm_outbound install skipped: %s", _outbound_err)
# ── Proxy-lookup cache (process-wide) ─────────────────────────────────
# requests/urllib re-read the Windows registry for proxy settings on EVERY
# HTTP call (getproxies_registry); the 2026-06-13 sluggishness dig caught the
# superadmin-report loop burning CPU there, and every other requests call pays
# it too. Cache the lookup with a TTL — same proxies, just not re-resolved per
# call. See core/proxy_cache.py.
try:
from core.proxy_cache import install_proxy_cache
install_proxy_cache()
except Exception as _proxy_err:
logging.getLogger(__name__).debug(
"proxy_cache install skipped: %s", _proxy_err)
# ── Boot-time tool-registry warmer ────────────────────────────────────
# Kicks a background daemon thread to pre-fill the 4 process-level tool
# caches (service_tools / system_introspect / skills / provider_gateway)
# so the FIRST user chat turn doesn't pay the ~80-100s cold-start
# penalty observed on 2026-05-13 09:55:23 (IPL turn 301eeed0). The
# warmer itself takes ~100s but runs in parallel with everything else;
# by the time a user types, the caches are warm. Failures are
# swallowed silently — the lazy on-call path retries any registry
# that was temporarily unavailable at boot.
def _spawn_tool_warmup_when_ready():
"""Deferred warmup spawn — runs once get_tools symbols are defined.
The actual warmer function `_warmup_tool_registries_in_background`
is defined further down in this file, so we wrap the spawn in a
short delay-thread that resolves it from globals() at call time.
"""
try:
import threading as _t
def _deferred_spawn():
try:
time.sleep(1)
except Exception:
logging.getLogger(__name__).exception("_deferred_spawn: swallowed Exception")
warmer = globals().get('_warmup_tool_registries_in_background')
if warmer:
try:
warmer()
except Exception as _e:
logging.getLogger(__name__).debug(
"tool-warmup invocation failed: %s", _e)
_t.Thread(target=_deferred_spawn, daemon=True,
name='tool-warmup-spawner').start()
except Exception as _ws_err:
logging.getLogger(__name__).debug(
"tool-warmup spawner failed: %s", _ws_err)
_spawn_tool_warmup_when_ready()
def _json_endpoint(f):
"""Wrap a Flask view so unhandled exceptions return ``{'error': ...}, 500``.
Must be defined before any module-level route registration block uses it
as a decorator — Python resolves decorator names at function-definition
time, not at call time.
"""
@wraps(f)
def _wrapped(*args, **kwargs):
try:
return f(*args, **kwargs)
except Exception as e:
return jsonify({'error': str(e)}), 500
return _wrapped
if _is_bundled:
# Bundled: root owns the handlers; let app.logger propagate to root
# so ALL module loggers (hevolveai, langchain, etc.) are captured.
app.logger.propagate = True
else:
# Standalone: only Flask's logger gets the handlers
app.logger.addHandler(stream_handler)
app.logger.addHandler(handler)
app.logger.propagate = False
# Security: Audit logging — redact API keys, JWTs, passwords from all logs
try:
from security.audit_log import apply_sensitive_filter_to_all
apply_sensitive_filter_to_all()
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception") # Degrade gracefully — logs will still work, just unredacted
# Test logging
app.logger.info('Logger initialized')
# Security: Apply middleware (headers, CORS, CSRF, host validation, API auth)
try:
from security.middleware import apply_security_middleware
apply_security_middleware(app)
app.logger.info("Security middleware applied")
except Exception as e:
app.logger.warning(f"Security middleware not applied: {e}")
# Boot-time tier safety check: warn if HEVOLVE_NODE_TIER is unset on non-bundled.
# A misconfigured central/regional node defaults to 'flat' and silently accepts
# body-sourced user_id without auth — the CISO flagged this as a data-exfil risk.
_boot_tier = os.environ.get('HEVOLVE_NODE_TIER')
_is_bundled = bool(os.environ.get('NUNBA_BUNDLED') or getattr(sys, 'frozen', False))
if not _boot_tier and not _is_bundled:
app.logger.warning(
'HEVOLVE_NODE_TIER not set — defaulting to "flat" (no auth on /chat). '
'Set HEVOLVE_NODE_TIER=regional or =central for multi-user deployments.')
elif _boot_tier in ('regional', 'central'):
# Verify JWT secret exists, otherwise auth enforcement is pointless
_jwt_secret = os.environ.get('SECRET_KEY', '') or app.config.get('SECRET_KEY', '')
if not _jwt_secret:
app.logger.error(
f'HEVOLVE_NODE_TIER={_boot_tier} but no SECRET_KEY configured! '
f'JWT auth will reject ALL requests. Set SECRET_KEY env var.')
# Security: Block inspect.getsource() for protected packages (hevolveai)
try:
from security.source_protection import install_source_guards
install_source_guards()
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception") # Non-fatal — source stripping is the primary defense
# ============================================================================
# HevolveSocial - Agent Social Network
# ============================================================================
try:
from integrations.social import social_bp, init_social
app.register_blueprint(social_bp)
init_social(app)
app.logger.info("HevolveSocial registered at /api/social")
except Exception as e:
app.logger.warning(f"HevolveSocial init skipped (non-critical): {e}")
try:
from integrations.distributed_agent import distributed_agent_bp
app.register_blueprint(distributed_agent_bp)
app.logger.info("distributed_agent_bp registered at /api/distributed")
except ImportError:
app.logger.info("distributed_agent module not available, skipping")
except Exception as e:
app.logger.warning(f"distributed_agent init skipped: {e}")
try:
from integrations.social.api_provision import provision_bp
app.register_blueprint(provision_bp)
app.logger.info("provision_bp registered at /api/provision")
except ImportError:
app.logger.info("Provision module not available, skipping")
except Exception as e:
app.logger.warning(f"Provision init skipped: {e}")
# Legacy ``register_consent_routes`` block removed in the
# consent-surface consolidation (orchestrator review acd11f55,
# 2026-04-25). The HTTP write surface for user consent now lives at
# ``integrations.social.consent_api`` (consent_bp, JWT-authed,
# append-only, mounted at ``/api/social/consent``). ``init_social``
# in ``integrations/social/__init__.py`` registers the new blueprint;
# no extra wiring is required here.
# Claude Code frontier endpoint — OpenAI-compatible /chat/completions backed by
# the authorized `claude -p` (flow #3: HARTOS wears Claude as its EXPERT tier).
# The EXPERT ModelBackend (model_registry) points its base_url here; autogen
# calls it like any local llama.cpp endpoint. Failures degrade to the in-house
# LLM via dispatch.py's existing fallback ladder.
try:
from integrations.providers.claude_code_endpoint import claude_code_bp
app.register_blueprint(claude_code_bp)
app.logger.info("Claude Code frontier endpoint registered at /api/claude/v1")
except ImportError:
app.logger.info("Claude Code endpoint not available, skipping")
except Exception as e:
app.logger.warning(f"Claude Code endpoint init skipped: {e}")
# Agent-engine API — the ledger surface the MCP daemon probe already depends on.
#
# WHY THIS WAS MISSING AND WHY IT MATTERS. integrations/mcp/_tool_impls.py
# documents a known false negative: `probe_agent_daemon()` reads `_running` /
# `_tick_count` off an imported module, and when Python (or, on HART OS, a
# SECOND PROCESS) resolves a different agent_daemon instance than the live one,
# it reports `daemon_enabled=false, _tick_count=0` while the daemon is happily
# ticking. Its defence is to ALSO fetch canonical ledger stats over Flask
# loopback, "shadow-immune" because the request lands on whichever singleton
# Flask actually resolved.
#
# That defence has never been able to run here: agent_engine_bp was registered
# nowhere. Measured on the box 2026-09-09 -- `list_routes` on this backend
# returns 829 routes and ZERO agent-engine, and /api/agent-engine/ledger/stats
# 404s on :6777 AND on the nunba socket. So the corrective probe 404'd, the
# module-attr view was the only view, and the agent engine was structurally
# unobservable from outside the process.
#
# The ledger is DB-backed (HEVOLVE_DB_PATH, shared by both processes), so
# serving it here answers for the node rather than for this process. Registered
# in its own try/except like every neighbour: a route-drop must never take the
# app down.
try:
from integrations.agent_engine import get_engine_blueprint as _get_agent_engine_bp
app.register_blueprint(_get_agent_engine_bp())
app.logger.info("Agent-engine API registered at /api/agent-engine/")
except ImportError:
app.logger.info("Agent-engine API not available, skipping")
except Exception as e:
app.logger.warning(f"Agent-engine API init skipped: {e}")
# MCP HTTP Bridge — exposes local MCP tools via REST for Nunba/external clients
try:
from integrations.mcp.mcp_http_bridge import mcp_local_bp, auto_register_local_mcp
app.register_blueprint(mcp_local_bp)
auto_register_local_mcp()
app.logger.info("MCP HTTP bridge registered at /api/mcp/local")
except ImportError:
app.logger.info("MCP HTTP bridge not available, skipping")
except Exception as e:
app.logger.warning(f"MCP HTTP bridge init skipped: {e}")
# Model Onboarding API — HuggingFace → GGUF → llama.cpp in one call
try:
from integrations.service_tools.model_onboarding import get_blueprint as _get_onboard_bp
app.register_blueprint(_get_onboard_bp())
app.logger.info("Model onboarding API registered at /api/models/")
except ImportError:
app.logger.info("Model onboarding not available, skipping")
except Exception as e:
app.logger.warning(f"Model onboarding init skipped: {e}")
# Robot Intelligence API — 7 intelligences fuse in parallel for any embodied AI
try:
from integrations.robotics.intelligence_api import robot_intelligence_bp
app.register_blueprint(robot_intelligence_bp)
app.logger.info("Robot Intelligence API registered at /api/robot/")
except ImportError:
app.logger.info("Robot Intelligence API not available, skipping")
except Exception as e:
app.logger.warning(f"Robot Intelligence API init skipped: {e}")
# Commercial API — the paid /api/v1/intelligence/* surface.
# Was coded but never registered with Flask, leaving 8 revenue endpoints
# (chat, analyze, generate, hivemind, usage, keys CRUD) dead. Routes:
# POST /api/v1/intelligence/chat
# POST /api/v1/intelligence/analyze
# POST /api/v1/intelligence/generate
# GET /api/v1/intelligence/hivemind
# GET /api/v1/intelligence/usage
# POST /api/v1/intelligence/keys
# GET /api/v1/intelligence/keys
# DELETE /api/v1/intelligence/keys/
# Tiers: free / starter / pro / enterprise (see TIER_CONFIG in
# integrations/agent_engine/commercial_api.py:32-36). Revenue split via
# revenue_aggregator: 90% users / 9% infra / 1% central.
# Free tier is always free — gatekeeping intelligence is not the goal;
# sustaining the hive is. This blueprint registration is the single
# wiring change between "revenue surface dead" and "customers can land".
try:
from integrations.agent_engine.commercial_api import commercial_api_bp
app.register_blueprint(commercial_api_bp)
app.logger.info(
"Commercial API registered at /api/v1/intelligence/ — "
"free/starter/pro/enterprise tiers, 90/9/1 revenue split")
except ImportError:
app.logger.info("Commercial API not available, skipping")
except Exception as e:
app.logger.warning(f"Commercial API init skipped: {e}")
# Central OTA control — GET /api/ota/latest (PUBLIC pointer nodes poll on their
# hart-ota-check timer), POST /api/ota/publish (account-gated, kicks the upgrade
# pipeline + fans a signed firmware_update fleet command), GET /api/ota/nodes
# (live per-node rollout) — is served by the fleet_update blueprint, registered
# inside integrations/social/__init__.py alongside the other social surfaces.
# (There is ONE OTA backend; the former agent_engine/ota_api.py duplicate was
# removed during integration — Flask routed to the social blueprint anyway, so
# it was dead code; its pipeline-kick + audit folded into api_fleet_update.py.)
# Hive Session API — Claude Code as hive worker node
try:
from integrations.coding_agent.claude_hive_session import get_blueprint as _get_hive_bp
app.register_blueprint(_get_hive_bp())
app.logger.info("Hive Session API registered at /api/hive/session/")
except ImportError:
app.logger.info("Hive Session API not available, skipping")
except Exception as e:
app.logger.warning(f"Hive Session API init skipped: {e}")
# Hive Signal Bridge API — channel signals dashboard
try:
from integrations.channels.hive_signal_bridge import create_signal_blueprint
app.register_blueprint(create_signal_blueprint())
app.logger.info("Hive Signal Bridge registered at /api/hive/signals/")
except ImportError:
app.logger.info("Hive Signal Bridge not available, skipping")
except Exception as e:
app.logger.warning(f"Hive Signal Bridge init skipped: {e}")
# Hive Benchmark Prover API — distributed benchmark proving
try:
from integrations.agent_engine.hive_benchmark_prover import create_benchmark_prover_blueprint
_bench_bp = create_benchmark_prover_blueprint()
if _bench_bp:
app.register_blueprint(_bench_bp)
app.logger.info("Benchmark Prover API registered at /api/hive/benchmark/")
except ImportError:
app.logger.info("Benchmark Prover not available, skipping")
except Exception as e:
app.logger.warning(f"Benchmark Prover init skipped: {e}")
# Robotics Hardware Bridge API — sensor/actuator registration and action execution
try:
from integrations.robotics.hardware_bridge import _create_blueprint as _create_hw_bp
_hw_bp = _create_hw_bp()
if _hw_bp:
app.register_blueprint(_hw_bp)
app.logger.info("Robotics Hardware Bridge registered at /api/robot/")
except ImportError:
app.logger.info("Robotics Hardware Bridge not available, skipping")
except Exception as e:
app.logger.warning(f"Robotics Hardware Bridge init skipped: {e}")
# Compute Optimizer API — system resource optimization
try:
from core.compute_optimizer import create_optimizer_blueprint, get_optimizer
_opt_bp = create_optimizer_blueprint()
if _opt_bp:
app.register_blueprint(_opt_bp)
_optimizer = get_optimizer()
_optimizer.start()
app.logger.info("Compute Optimizer registered and started")
except ImportError:
app.logger.info("Compute Optimizer not available, skipping")
except Exception as e:
app.logger.warning(f"Compute Optimizer init skipped: {e}")
# App Marketplace — publish, compare, distribute, auto-promote AI apps
try:
from integrations.agent_engine.app_marketplace import marketplace_bp
app.register_blueprint(marketplace_bp)
app.logger.info("App Marketplace registered at /api/marketplace/")
except ImportError:
app.logger.info("App Marketplace not available, skipping")
except Exception as e:
app.logger.warning(f"App Marketplace init skipped: {e}")
# McGroce agentic commerce — /api/commerce/session (Tomcat -> HARTOS token
# exchange), /mandates/, /health. Also in blueprint_registry for the
# Nunba path; _try_register skips it there when this one already ran.
try:
from integrations.commerce.commerce_api import commerce_bp
app.register_blueprint(commerce_bp)
app.logger.info("Commerce API registered at /api/commerce/")
except ImportError:
app.logger.info("Commerce API not available, skipping")
except Exception as e:
app.logger.warning(f"Commerce API init skipped: {e}")
# Resource Governor — start background monitoring (task #260: watchdog-registered)
try:
from core.resource_governor import get_governor
_gov = get_governor()
_gov.start()
app.logger.info(f"Resource Governor started (mode={_gov.get_mode()})")
# Register both daemon loops with the watchdog. Monitor runs every 5s;
# proactive waits up to 5s per iteration. Allow 4× grace for noisy
# system-state probes (nvidia-smi, /proc).
try:
from security.node_watchdog import get_watchdog
_wd = get_watchdog()
if _wd and _gov._running:
_wd.register('resource_governor_monitor',
expected_interval=20,
restart_fn=_gov.start, stop_fn=_gov.stop)
_wd.register('resource_governor_proactive',
expected_interval=20,
restart_fn=_gov.start, stop_fn=_gov.stop)
app.logger.info("Resource Governor registered with watchdog")
except Exception as e:
app.logger.debug(f"Governor watchdog registration skipped: {e}")
except ImportError:
logging.getLogger(__name__).debug(": swallowed ImportError")
except Exception as e:
app.logger.warning(f"Resource Governor start skipped: {e}")
# NOTE — the AI-senses cross-process authority server (Phase 7) is NOT started
# here. core.ai_sensing._state is mutated ONLY by POST /api/shell/ai-sensing,
# which is served by the LiquidUI shell process (liquid_ui_service.py), not this
# :6777 backend. The authority socket must reflect the SAME _state the human
# mutates, so start_authority_server() is called THERE (the canonical holder).
# Starting it here too would bind a SECOND copy of _state that the kill-switch
# never updates — reporting a stale "screen allowed" after the human cut it. One
# writer, one authority. (Co-located bundles where both run in one process are
# unaffected: a single start_authority_server() in the shared heap still wins.)
# Central Orchestrator Client — heartbeat to hevolve.ai central + master
# kill-switch polling. Env-gated: no-op when HEVOLVE_CENTRAL_ORCHESTRATOR_URL
# is unset (default flat-mode install), so we burn ZERO resources on a
# poll loop nobody asked for. When configured, the client posts small
# heartbeats (node_id + guardrail_hash + benchmark best-scores + halted
# flag), polls /halt for a master-signed stop signal, and routes that
# through HiveCircuitBreaker.halt_network() — which itself verifies the
# master-key signature before tripping. See docs/ml_intern_brief §5-D.
try:
from core import central_orchestrator_client as _coc_mod
_coc = _coc_mod.get_client()
if _coc.is_configured():
if _coc.start():
app.logger.info(
"Central Orchestrator Client started "
"(env-gated: HEVOLVE_CENTRAL_ORCHESTRATOR_URL set)"
)
# Watchdog — catch silent thread death. Expected interval
# is 2× heartbeat (heartbeat loop wakes at min(heartbeat,
# halt_poll, 5s) so 120s gives comfortable headroom without
# triggering on a single missed poll.
try:
from security.node_watchdog import get_watchdog
_wd = get_watchdog()
if _wd:
_wd.register(
'central_orchestrator_client',
expected_interval=120,
restart_fn=_coc.start,
stop_fn=_coc.stop,
)
app.logger.info(
"Central Orchestrator Client registered with watchdog"
)
except ImportError:
logging.getLogger(__name__).debug(": swallowed ImportError")
except Exception as e:
app.logger.debug(
f"Central Orchestrator watchdog registration skipped: {e}"
)
# atexit — signal the loop to exit cleanly on process shutdown.
# Daemon thread exits with the process regardless; this just
# flushes any in-flight heartbeat.
import atexit as _atexit
_atexit.register(_coc.stop)
else:
# start() returned False — either already running, or node
# tier is 'central' itself (brief's design: central doesn't
# self-heartbeat). Both paths log inside start().
pass
else:
app.logger.debug(
"Central Orchestrator Client inactive "
"(HEVOLVE_CENTRAL_ORCHESTRATOR_URL unset) — flat-mode default"
)
except ImportError:
app.logger.debug(
"Central Orchestrator Client not available, skipping"
)
except Exception as e:
app.logger.warning(f"Central Orchestrator Client init skipped: {e}")
# Instruction Queue API — never miss a user instruction
try:
from integrations.agent_engine.instruction_queue import (
get_queue, enqueue_instruction, pull_user_batch,
)
@app.route('/api/instructions/enqueue', methods=['POST'])
def _api_enqueue_instruction():
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
text = data.get('text', '')
if not text:
return jsonify({'error': 'text is required'}), 400
inst = enqueue_instruction(
user_id, text,
priority=data.get('priority', 5),
tags=data.get('tags'),
context=data.get('context'),
)
return jsonify({'success': True, 'instruction': inst.to_dict()})
@app.route('/api/instructions/pending', methods=['GET'])
def _api_pending_instructions():
user_id = request.args.get('user_id', '1')
q = get_queue(user_id)
pending = q.get_pending()
return jsonify({
'success': True,
'pending': [i.to_dict() for i in pending],
'stats': q.stats(),
})
@app.route('/api/instructions/batch', methods=['POST'])
def _api_pull_batch():
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
max_tokens = data.get('max_tokens', 8000)
batch = pull_user_batch(user_id, max_tokens=max_tokens)
if not batch:
return jsonify({'success': True, 'batch': None, 'message': 'No pending instructions'})
return jsonify({'success': True, 'batch': batch.to_dict()})
@app.route('/api/instructions/cancel', methods=['POST'])
def _api_cancel_instruction():
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
instruction_id = data.get('instruction_id', '')
if not instruction_id:
return jsonify({'error': 'instruction_id is required'}), 400
q = get_queue(user_id)
q.cancel(instruction_id)
return jsonify({'success': True})
@app.route('/api/instructions/plan', methods=['GET'])
def _api_execution_plan():
"""Pull execution plan — agents query this to see what's available.
Returns dependency-aware waves so the agent can dispatch
independent items in parallel and dependent items in order.
This is a PULL API — agents decide when to fetch work.
"""
user_id = request.args.get('user_id', '1')
max_tokens = int(request.args.get('max_tokens', '8000'))
q = get_queue(user_id)
plan = q.pull_execution_plan(max_tokens=max_tokens)
if not plan:
return jsonify({'success': True, 'plan': None, 'message': 'No pending instructions'})
return jsonify({'success': True, 'plan': plan.to_dict()})
@app.route('/api/instructions/complete', methods=['POST'])
def _api_complete_instruction():
"""Mark a single instruction as done (used by agents after execution).
Notifies SmartLedger to unblock dependent instructions.
"""
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
instruction_id = data.get('instruction_id', '')
result = data.get('result')
if not instruction_id:
return jsonify({'error': 'instruction_id is required'}), 400
q = get_queue(user_id)
q.complete_instruction(instruction_id, result=result)
return jsonify({'success': True})
@app.route('/api/instructions/fail', methods=['POST'])
def _api_fail_instruction():
"""Mark a single instruction as failed — returns to queue for retry."""
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
instruction_id = data.get('instruction_id', '')
error = data.get('error', 'unknown')
if not instruction_id:
return jsonify({'error': 'instruction_id is required'}), 400
q = get_queue(user_id)
q.fail_instruction(instruction_id, error=error)
return jsonify({'success': True})
@app.route('/api/instructions/drain', methods=['POST'])
def _api_drain_queue():
"""Trigger wave-based drain — dispatches with parallel + sequential ordering.
Agent or daemon calls this to execute all pending instructions.
Independent instructions dispatch in parallel threads.
Dependent instructions wait for prerequisites.
"""
data = request.get_json(silent=True) or {}
user_id = str(data.get('user_id', '1'))
max_tokens = data.get('max_tokens', 8000)
try:
from integrations.agent_engine.dispatch import drain_instruction_queue
result = drain_instruction_queue(user_id, max_tokens=max_tokens)
if result:
return jsonify({'success': True, 'result': result[:2000]})
return jsonify({'success': True, 'result': None, 'message': 'Queue empty or all failed'})
except Exception as e:
return jsonify({'error': str(e)}), 500
app.logger.info("Instruction queue API routes registered (8 endpoints)")
except Exception as e:
app.logger.warning(f"Instruction queue init skipped: {e}")
# ── Credential Vault API — frontend submits missing credentials ──────
try:
from hartos.ai_key_vault import AIKeyVault as _VaultCls, is_local_request
@app.route('/api/credentials/submit', methods=['POST'])
@_json_endpoint
def _api_credentials_submit():
"""Accept a credential from the frontend — LOCALHOST ONLY.
Secrets never leave the user's device. This endpoint rejects
any request that doesn't originate from the local machine.
"""
if not is_local_request():
return jsonify({'error': 'Credential endpoints are localhost only. '
'Secrets never leave your device.'}), 403
data = request.get_json(silent=True) or {}
key_name = (data.get('key_name') or '').strip()
value = (data.get('value') or '').strip()
if not key_name or not value:
return jsonify({'error': 'key_name and value are required'}), 400
vault = _VaultCls.get_instance()
# A name the process holds that this vault did not store (PATH,
# HTTPS_PROXY...) is left alone by store_credential and answered as
# any other, so this endpoint does not say which variables exist.
resolved = vault.store_credential(
key_name=key_name,
value=value,
channel_type=data.get('channel_type', ''),
)
return jsonify({'success': True, 'key_name': resolved})
@app.route('/api/credentials/pending', methods=['GET'])
@_json_endpoint
def _api_credentials_pending():
"""List pending credential requests — LOCALHOST ONLY."""
if not is_local_request():
return jsonify({'error': 'Credential endpoints are localhost only.'}), 403
vault = _VaultCls.get_instance()
return jsonify({'pending': vault.get_pending_requests()})
app.logger.info("Credential vault API routes registered (2 endpoints)")
except ImportError:
logging.getLogger(__name__).debug(": swallowed ImportError")
except Exception as e:
app.logger.warning(f"Credential vault API init skipped: {e}")
# ── Kong API Gateway — Completions API proxy + metering ───────────────
try:
@app.route('/v1/chat/completions', methods=['POST'])
@_json_endpoint
def _completions_proxy():
"""OpenAI-compatible completions — proxied through Kong metering.
SDK clients hit Kong → Kong routes here → we forward to HevolveAI.
Token usage is metered for 90/9/1 revenue split.
"""
import requests as _req
data = request.get_json(silent=True) or {}
# Resolve the LLM the same way every other consumer does, instead of a
# private HEVOLVE_API_URL that defaulted to localhost:8000. That default
# is correct for an on-device install (llama.cpp on :8000 is the whole
# point of local Nunba), but on the central tier nothing runs there, so
# this proxy 502'd while Langchain/Autogen worked off the gateway. The
# resolver probes candidates and picks the first reachable one — the
# local model on a device, the gateway on central — so a single source
# of truth is correct in both topologies. Explicit HEVOLVE_API_URL still
# wins if set, for operators who want to pin it.
base = os.environ.get('HEVOLVE_API_URL')
if base:
base = base.rstrip('/')
# A pinned base may or may not end in /v1; build the completions
# path without doubling it.
target = base + ('/chat/completions' if base.endswith('/v1')
else '/v1/chat/completions')
headers = {'Content-Type': 'application/json'}
else:
# The ONE configured backend (core.autogen_config): the local
# model on a device, the configured API on central, same as every
# other chat/completions caller in this module.
target = GPT_API
headers = dict(LLM_AUTH_HEADERS)
headers['Content-Type'] = 'application/json'
try:
resp = _req.post(
target,
json=data,
headers=headers,
timeout=120
)
result = resp.json()
except Exception as fwd_err:
return jsonify({'error': f'HevolveAI backend unavailable: {fwd_err}'}), 502
# The serving half of a hive expert exchange: a peer's person asked,
# this node's operator earns the compute served (owner ruling
# 2026-09-26). No requester header (an SDK client) earns nothing.
if resp.status_code == 200:
from integrations.agent_engine.budget_gate import credit_served_completion
credit_served_completion(request.headers, data, result)
# Meter usage for the 90/9/1 revenue split. This passed keywords
# record_metered_usage does not accept (provider/model/tokens/source);
# the TypeError was swallowed below, so SDK usage was NEVER metered
# (hevolveai Master 11.435 S2). meter_llm_call owns the contract;
# 'hive' is the settled cross-operator source. Two things stay open
# for the owner: MeteredAPIUsage has no consumer field (the old
# 'sdk:' tag had nowhere to go), and a LOCAL model prices at
# 0 via spark_per_1k, so SDK use of a local model records no revenue.
usage = result.get('usage', {}) or {}
total_tokens = usage.get('total_tokens', 0) or 0
if total_tokens > 0:
try:
from integrations.agent_engine.budget_gate import meter_llm_call
tokens_in = usage.get('prompt_tokens', 0) or 0
tokens_out = usage.get('completion_tokens',
max(0, total_tokens - tokens_in)) or 0
meter_llm_call(
model=data.get('model', 'hevolve'),
tokens_in=tokens_in,
tokens_out=tokens_out,
task_source='hive',
)
except Exception:
logging.getLogger(__name__).exception("_completions_proxy: swallowed Exception") # metering failure must not block response
return jsonify(result)
@app.route('/api/gateway/metering', methods=['GET'])
@_json_endpoint
def _gateway_metering():
"""SDK usage metering stats for billing dashboard."""
try:
from integrations.social.models import db_session
from integrations.agent_engine.budget_gate import metered_usage_by_model
with db_session(commit=False) as session:
return jsonify({'providers': metered_usage_by_model(session)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/gateway/register', methods=['POST'])
@_json_endpoint
def _gateway_register_node():
"""Register a compute node as Kong upstream target.
Called by compute_mesh when a node joins the hive.
"""
data = request.get_json(silent=True) or {}
target = data.get('target') # e.g. "192.168.1.5:8000"
if not target:
return jsonify({'error': 'target is required'}), 400
kong_admin = os.environ.get('KONG_ADMIN_URL', 'http://localhost:8001')
upstream = data.get('upstream', 'hevolve-nodes')
try:
import requests as _req
resp = _req.post(
f'{kong_admin}/upstreams/{upstream}/targets',
json={'target': target, 'weight': 100},
timeout=10
)
return jsonify({'registered': True, 'status': resp.status_code})
except Exception as e:
return jsonify({'error': f'Kong admin unreachable: {e}'}), 502
app.logger.info("Kong gateway API routes registered (3 endpoints: completions proxy, metering, node registration)")
except Exception as e:
app.logger.warning(f"Kong gateway API init skipped: {e}")
# ============================================================================
# Google A2A Protocol Initialization
# ============================================================================
# Initialize A2A server for cross-platform agent communication
try:
app.logger.info("Initializing Google A2A Protocol server...")
from core.port_registry import get_port as _a2a_get_port
a2a_server = initialize_a2a_server(app, base_url=f"http://localhost:{_a2a_get_port('backend')}")
app.logger.info("Google A2A Protocol server initialized successfully")
# Register all agents with A2A
register_all_agents()
app.logger.info("All agents registered with Google A2A Protocol")
except Exception as e:
app.logger.warning(f"Google A2A Protocol initialization error (non-critical): {e}")
# openAPI spec
try:
spec = OpenAPISpec.from_file(
"./openapi.yaml"
)
except Exception as e:
app.logger.warning(f"Could not load OpenAPI spec: {e}")
spec = None
# Load config.json — graceful fallback when keys are absent (e.g. bundled Nunba install
# where the config.json is Nunba's URL-template file, not langchain's API-key file).
# In frozen Nunba builds, langchain config is bundled as langchain_config.json to avoid
# collision with the project root config.json (which has IP_ADDRESS settings).
config = {}
for _cfg_name in ('langchain_config.json', 'config.json'):
try:
with open(_cfg_name, 'r') as f:
_loaded = json.load(f)
# Distinguish langchain config (has API keys) from Nunba URL config (has IP_ADDRESS)
if 'IP_ADDRESS' not in _loaded or any(k.endswith('_API_KEY') for k in _loaded):
config = _loaded
break
elif not config:
config = _loaded # fall through to try langchain_config.json first
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception")
# global variables
# Token counting: single source — core.token_utils.count_tokens_for_text
# (tiktoken with a word-split fallback). Replaces the old module-global
# `encoding` object + the inline `len(encoding.encode(x)) if encoding else
# x.split()` chains (#112), and removes the import-time crash when tiktoken was
# unavailable (the old except ran tiktoken.get_encoding on a None tiktoken).
from core.token_utils import count_tokens_for_text
# api and keys — use config if available, otherwise keep existing env vars / empty
# A key absent from this tuple can be set in config.json and still never reach
# the code that reads it. That is how SEARXNG_URL sat configured-but-dead: the
# reader consults os.environ only, and nothing copied it across. Add the env
# name here whenever a new provider starts reading one.
# The LLM itself is NOT configured here: HEVOLVE_LLM_ENDPOINT_URL /
# HEVOLVE_LLM_MODEL_NAME / HEVOLVE_LLM_API_KEY are the ONE configured LLM
# (core/autogen_config.py); a vendor-named key (QWEN_*, GLM_*, GROQ_*) picks
# nothing any more (#69).
for _cfg_key in ('OPENAI_API_KEY', 'GOOGLE_CSE_ID', 'GOOGLE_API_KEY',
'NEWS_API_KEY', 'SERPAPI_API_KEY'):
if _cfg_key in config:
os.environ[_cfg_key] = config[_cfg_key]
else:
os.environ.setdefault(_cfg_key, '')
# Mode-aware inference: pass LLM endpoint to HevolveAI for non-flat deployments
_node_tier = os.environ.get('HEVOLVE_NODE_TIER', 'flat')
_active_cloud = os.environ.get('HEVOLVE_ACTIVE_CLOUD_PROVIDER', '')
if _node_tier in ('regional', 'central'):
# No default model name: the configured endpoint decides the model, and an
# endpoint without a name is reported by core.autogen_config, not guessed.
os.environ.setdefault('HEVOLVE_LLM_ENDPOINT_URL', config.get('OPENAI_API_BASE', ''))
os.environ.setdefault('HEVOLVE_LLM_API_KEY', config.get('OPENAI_API_KEY', ''))
os.environ.setdefault('HEVOLVE_LLM_MODEL_NAME', config.get('OPENAI_MODEL', ''))
elif _active_cloud and os.environ.get('HEVOLVE_LLM_API_KEY'):
# Wizard-configured cloud provider (flat mode desktop user).
# Vault already populated HEVOLVE_LLM_* env vars via export_to_env() in app.py.
# HevolveAI's create_learning_llm_config() reads these automatically.
pass
# Cloud fallback for adaptive routing (flat mode — offload when local CPU overloaded)
if config.get('CLOUD_FALLBACK_URL'):
os.environ.setdefault('HEVOLVE_CLOUD_FALLBACK_URL', config['CLOUD_FALLBACK_URL'])
os.environ.setdefault('HEVOLVE_CLOUD_FALLBACK_KEY', config.get('CLOUD_FALLBACK_KEY', ''))
os.environ.setdefault('HEVOLVE_CLOUD_FALLBACK_MODEL', config.get('CLOUD_FALLBACK_MODEL', ''))
# Zep removed — replaced by SimpleMem (local, zero-latency)
# ZEP_API_URL / ZEP_API_KEY no longer needed
# API endpoints — fall back to IP_ADDRESS sub-dict or empty when keys missing.
# In bundled Nunba mode the config.json is URL-template format with IP_ADDRESS dict;
# in cloud/dev mode it has flat top-level keys.
_ip = config.get('IP_ADDRESS', {})
def _resolve_llm_endpoint(registry_fn_name: str, env_var: str) -> str:
"""Resolve an LLM chat-completions URL via port_registry → env var fallback.
Both GPT_API and DRAFT_GPT_API use the same 2-step resolution:
1. core.port_registry.() → base URL → + /chat/completions
2. os.environ[env_var] → base URL → + /chat/completions
Returns '' if neither resolves.
"""
url = ''
try:
import importlib
_mod = importlib.import_module('core.port_registry')
_base = getattr(_mod, registry_fn_name)()
if _base:
url = _base.rstrip('/') + '/chat/completions'
except Exception:
logging.getLogger(__name__).exception("_resolve_llm_endpoint: swallowed Exception")
if not url:
_env = os.environ.get(env_var, '')
if _env:
url = _env.rstrip('/') + '/chat/completions'
return url
# The ONE LLM this process talks to, decided in core.autogen_config from the
# configured trio (HEVOLVE_LLM_ENDPOINT_URL / _MODEL_NAME / _API_KEY). Every
# chat/completions POST in this module goes to GPT_API or DRAFT_GPT_API with
# LLM_MODEL_NAME in the body, and _pooled_post_with_refusal_check adds
# LLM_AUTH_HEADERS on the way out; no site picks a model or a port of its own.
from core.autogen_config import (
resolve_llm_backend, llm_http_target, with_local_fallback,
get_autogen_config_list)
LLM_KIND, _llm_entry = resolve_llm_backend()
LLM_MODEL_NAME = _llm_entry['model']
if LLM_KIND == 'api':
# A configured endpoint (central, a regional node with a key, a desktop
# with a cloud provider): the single API serves every role, so the draft
# slot IS the main model. config.json's GPT_API (the bundled llama URL)
# must not win here: on central it named a server that never ran inside
# the container (#69).
GPT_API, LLM_AUTH_HEADERS, _ = llm_http_target()
DRAFT_GPT_API = GPT_API
else:
LLM_AUTH_HEADERS = {}
# Main LLM endpoint (4B on :8080). Config.json first, then registry/env fallback.
GPT_API = config.get('GPT_API', _ip.get('gpt3_url', ''))
if not GPT_API:
GPT_API = _resolve_llm_endpoint('get_local_llm_url', 'HEVOLVE_LOCAL_LLM_URL')
# Draft LLM endpoint: the 0.8B on :8081 that serves casual_conv=True.
# When casual_conv=True, CustomGPT._call() posts here instead of GPT_API.
# The 0.8B responds in ~300ms with a short reply. If the response signals
# delegate=local (needs tools/reasoning), the caller re-dispatches with
# casual_conv=False which hits GPT_API (the 4B on :8080).
DRAFT_GPT_API = _resolve_llm_endpoint('get_local_draft_url', 'HEVOLVE_LOCAL_DRAFT_URL')
FAV_TEACHER_API = config.get('FAV_TEACHER_API', '')
DREAMBOOTH_API = config.get('DREAMBOOTH_API', '')
STABLE_DIFF_API = config.get('STABLE_DIFF_API', '')
# CRAWLAB_API removed — web crawling is now in-process via integrations.web_crawler
RAG_API = config.get('RAG_API', '')
# Endpoint resolution — single source of truth in core/config_cache.py
# Automatically resolves to localhost:5000 in bundled mode, cloud URLs otherwise.
from core.config_cache import (
get_db_url, get_action_api, get_student_api,
get_vision_api, is_bundled as _config_is_bundled,
get_central_db_url, flag_value,
)
DB_URL = get_db_url()
# Distinct from DB_URL: always points at the central cloud (kong-routed)
# instead of collapsing to localhost in bundled mode. Used by /prompts
# and /prompts/public to merge in agents the user owns on hevolve.ai
# but never synced down to this machine. Empty string disables the
# cross-device merge (local-only listing) — matches the same fail-quiet
# contract _merge_prompts_with_cloud already has on cloud failures.
CENTRAL_DB_URL = get_central_db_url()
ACTION_API = get_action_api()
STUDENT_API = get_student_api()
LLAVA_API = get_vision_api()
if _config_is_bundled():
logging.getLogger(__name__).info(
f"Bundled mode: DB/Action/Student/Vision APIs → {DB_URL}"
)
# ============================================================================
# Embodied AI Learning Pipeline (HevolveAI — in-process, no extra port)
# ============================================================================
_learning_provider = None
_hive_mind = None
_trace_recorder = None
def _is_bundled() -> bool:
"""Detect whether we are pip-installed inside Nunba (flat mode).
When Nunba imports us via ``hartos_backend_adapter``, that module is
already in ``sys.modules`` by the time our daemon thread runs. In
standalone mode (``python hart_intelligence_entry.py``, ``start_with_tracing.bat``)
it is absent. No env-vars or mode flags required.
"""
return 'hartos_backend_adapter' in sys.modules
def _has_cloud_api() -> bool:
"""Return True if the user configured an external cloud / API endpoint."""
return bool(os.environ.get('HEVOLVE_LLM_ENDPOINT_URL', '').strip())
def _wait_for_llm_server(url=None, timeout=15):
if url is None:
from core.port_registry import get_local_llm_url
url = get_local_llm_url().replace('/v1', '')
"""Wait for llama.cpp server, giving parent process time to start it.
In Nunba (flat mode), the desktop app starts llama.cpp in a background
thread. This function polls the health endpoint so HevolveAI sees an
existing server and reuses it instead of auto-starting a second one.
In standalone mode nobody else starts the server, so after *timeout*
seconds we return False and let HevolveAI auto-start as usual.
Returns True if server found, False if timeout expired.
"""
import urllib.request
import urllib.error
_logger = logging.getLogger(__name__)
for i in range(timeout):
try:
req = urllib.request.urlopen(f'{url}/health', timeout=2)
if req.status == 200:
_logger.info(
f"[EmbodiedAI] llama.cpp server ready at {url} "
f"(waited {i}s)")
return True
except (urllib.error.URLError, OSError):
logging.getLogger(__name__).warning("_wait_for_llm_server: swallowed urllib.error.URLError, OSError", exc_info=True)
time.sleep(1)
_logger.info(
f"[EmbodiedAI] No server on {url} after {timeout}s "
"\u2014 HevolveAI will auto-start")
return False
def _init_learning_pipeline():
"""Initialize HevolveAI's learning pipeline in-process.
Instead of starting a separate server on port 8000,
we import and initialize the learning components directly.
world_model_bridge calls these functions without HTTP overhead.
Behaviour depends on context (auto-detected, no env vars needed):
**Bundled (Nunba, flat mode):**
Nunba owns the llama.cpp lifecycle. We wait up to 30 s for
Nunba to start it. If it appears we reuse it. If it never
appears we skip the learning provider entirely — chat still
works, only RL-EF / hivemind is disabled. We NEVER auto-start
a second server on the user's machine.
**Standalone (start_with_tracing.bat, ``python hart_intelligence_entry.py``):**
Brief 5 s wait (in case user already has llama.cpp running).
If nothing responds, ``create_learning_llm_config()`` calls
HevolveAI which auto-starts its own server. Default behaviour,
no mode config needed.
**Cloud API configured (``HEVOLVE_LLM_ENDPOINT_URL``):**
Skip local server wait entirely — HevolveAI's Priority 0 path
routes to the external endpoint.
"""
global _learning_provider, _hive_mind, _trace_recorder
# RFC-B (2026-08-25): one learning home in bundled mode. The docstring's
# "instead of starting a separate server on port 8000" predates
# hevolveai_supervisor, which now always spawns (or adopts) that server
# in bundled Nunba and exports HEVOLVEAI_API_URL before this delayed
# init runs (measured: export 19:15:49 vs this init 19:19:13; export
# ~18:23 vs init 18:27:15). Running BOTH stacks grew commit ~23GB/h
# (snapshots 21:40→22:17) and OOM-killed the app at 22:2x. When bundled
# and a server is designated, the child is the sole learning home; the
# WorldModelBridge already carries stats/experiences/skills over HTTP
# (the http_disabled heal + the Direction-B poller, both live-proven).
if _is_bundled() and os.environ.get('HEVOLVEAI_API_URL'):
logging.getLogger(__name__).info(
"learning pipeline: skipping in-process init — supervisor-managed "
"hevolveai at %s is the learning home (RFC-B)",
os.environ['HEVOLVEAI_API_URL'])
return
try:
# G2: Ensure HevolveArmor's import hook is installed before the first
# `import hevolveai.*` statement runs below. `try_import_hevolveai`
# is idempotent and a no-op when the package is already loaded or
# no armored bundle is present on disk.
try:
from security.native_hive_loader import try_import_hevolveai
try_import_hevolveai('hevolveai')
except ImportError:
# security package missing — fall through to the raw import path
logging.getLogger(__name__).debug("_init_learning_pipeline: swallowed ImportError")
# Bypass rl_ef/__init__.py's graceful-degradation swallow
# (which binds every symbol to None on any ImportError /
# AttributeError / OSError / RuntimeError, then leaves us with
# the unhelpful "rl_ef symbols are None" warning that the guard
# at L1703 emits). Direct-module import surfaces the real
# import-time failure so it lands in the `except Exception as e`
# at L1757 with exc_info=True.
from hevolveai.embodied_ai.rl_ef.learning_llm_provider import (
create_learning_llm_config,
register_learning_provider,
)
from hevolveai.embodied_ai.monitoring.trace_recorder import get_trace_recorder
from hevolveai.embodied_ai.learning.hive_mind import HiveMind, AgentCapability
_logger = logging.getLogger(__name__)
bundled = _is_bundled()
cloud = _has_cloud_api()
_logger.info(
f"[EmbodiedAI] Initializing learning pipeline "
f"(bundled={bundled}, cloud_api={cloud})...")
# ── Decide how to handle the local llama.cpp server ──
if cloud:
# Cloud endpoint configured — HevolveAI uses it directly,
# no need to wait for or start a local server.
_logger.info(
"[EmbodiedAI] Cloud API configured — skipping local server wait")
elif bundled:
# Nunba owns the llama.cpp lifecycle (port 8080).
# Wait generously, but NEVER auto-start a second server.
server_found = _wait_for_llm_server(timeout=30)
if not server_found:
_logger.warning(
"[EmbodiedAI] Nunba's llama.cpp not ready after 30 s "
"— learning disabled (chat still works)")
return
else:
# Standalone — brief courtesy wait then let HevolveAI auto-start.
_wait_for_llm_server(timeout=5)
# Trace recorder
recordings_dir = os.path.join(
os.path.expanduser('~'), '.hevolveai', 'recordings')
os.makedirs(recordings_dir, exist_ok=True)
_trace_recorder = get_trace_recorder(recordings_dir)
# Learning provider (wraps llama.cpp on port 8080 or cloud endpoint)
#
# rl_ef/__init__.py uses a graceful-degradation pattern: when an
# optional sibling import (torch/cuda/transformers stack) fails,
# it binds create_learning_llm_config = register_learning_provider
# = None instead of raising. So a `from … import X` succeeds with
# X bound to None. Calling None(...) is a TypeError that crashes
# the *whole* pipeline (HiveMind included) — guard explicitly so
# only the learning-provider step is skipped.
if create_learning_llm_config is None or register_learning_provider is None:
_logger.warning(
"[EmbodiedAI] rl_ef symbols are None — an optional dep "
"(likely torch/cuda) failed to load inside hevolveai. "
"Trace recording + HiveMind continue; learning provider "
"skipped.")
else:
_domain = 'general'
_wizard_key = os.environ.get('HEVOLVE_LLM_API_KEY', '') or None
llm_config = create_learning_llm_config(
domain=_domain, fallback_api_key=_wizard_key)
if '_provider' in llm_config:
_learning_provider = llm_config['_provider']
register_learning_provider(_domain, _learning_provider)
_logger.info(
"[EmbodiedAI] Learning provider ready (RL-EF + episodic memory)")
else:
_logger.warning(
"[EmbodiedAI] Learning provider init returned no provider")
# HiveMind
if HiveMind is None or AgentCapability is None:
_logger.warning(
"[EmbodiedAI] HiveMind/AgentCapability symbols are None — "
"skipping HiveMind registration.")
else:
import uuid
instance_id = f"hevolve_{uuid.uuid4().hex[:8]}"
# HiveMind signature changed: the old `max_agents=100` kwarg
# is no longer accepted (live evidence frozen_debug.log 7×
# 2026-05-13/14: `TypeError: __init__() got an unexpected
# keyword argument 'max_agents'`). Today's HiveMind is a
# latent-space collaboration substrate (Procrustes-aligned)
# whose __init__ takes (common_dim=256, num_shards=4,
# fusion_method="attention", device="cpu") — no agent cap
# because cardinality is bounded by register_agent calls,
# not a constructor arg. Verified against
# hevolveai/src/.../learning/hive_mind.py:583-589. Defaults
# are sane for the desktop tier; device stays 'cpu' so we
# don't fight TTS for VRAM. Drop the kwarg.
_hive_mind = HiveMind()
_hive_mind.register_agent(
agent_id=instance_id,
agent_type='hevolve_orchestrator',
latent_dim=2048,
capabilities=[
AgentCapability.ENCODE,
AgentCapability.DECODE,
AgentCapability.REASON,
],
)
_logger.info(f"[EmbodiedAI] HiveMind registered as {instance_id}")
except ImportError as e:
logging.getLogger(__name__).warning(
f"[EmbodiedAI] HevolveAI not installed — learning disabled: {e}")
except Exception as e:
logging.getLogger(__name__).error(
f"[EmbodiedAI] Learning pipeline init failed: {e}",
exc_info=True)
def get_learning_provider():
"""Get the in-process learning provider (for world_model_bridge)."""
return _learning_provider
def get_hive_mind():
"""Get the in-process HiveMind instance (for world_model_bridge)."""
return _hive_mind
# Boot learning pipeline in background — delayed to avoid torch circular import.
# Threads that import torch race with the main thread's own torch imports,
# causing "partially initialized module 'torch'" errors.
def _delayed_learning_init():
import time
time.sleep(5) # Let main thread finish all imports first
_init_learning_pipeline()
threading.Thread(
target=_delayed_learning_init, daemon=True,
name='embodied_ai_init').start()
# ============================================================================
# Vision Pipeline (VisionService — MiniCPM sidecar + FrameStore)
# ============================================================================
_vision_service = None
def _init_vision_service():
"""Start VisionService in standalone mode only.
In bundled mode (Nunba), Nunba owns the VisionService lifecycle
and stores it at ``__main__._vision_service``. We skip here to
prevent double-start.
"""
global _vision_service
if _is_bundled():
logging.getLogger(__name__).info(
"[Vision] Bundled mode — Nunba owns VisionService lifecycle")
return
try:
from integrations.vision import VisionService
_vision_service = VisionService()
_vision_service.start()
logging.getLogger(__name__).info(
"[Vision] VisionService started (standalone mode)")
except ImportError:
logging.getLogger(__name__).info(
"[Vision] vision module not available — disabled")
except Exception as e:
logging.getLogger(__name__).warning(
f"[Vision] VisionService failed to start: {e}")
def get_vision_service():
"""Get the active VisionService instance.
Checks module-level var first, then falls back to Nunba's
``__main__._vision_service``; if that misses (cx_Freeze on macOS
mounts ``main.py`` under an opaque module name so neither ``__main__``
nor ``main`` matches), scans the live object graph for a
``VisionService`` instance. Safe because ``_start_vision_service``
only ever creates one.
"""
if _vision_service is not None:
return _vision_service
main_mod = sys.modules.get('__main__')
if main_mod:
_svc = getattr(main_mod, '_vision_service', None)
if _svc is not None:
return _svc
# cx_Freeze fallback: scan the heap.
try:
import gc
from integrations.vision.vision_service import VisionService
for _obj in gc.get_objects():
if isinstance(_obj, VisionService):
return _obj
except Exception:
pass
return None
def get_frame_store():
"""Get the active FrameStore (from VisionService)."""
svc = get_vision_service()
return svc.store if svc else None
def _wire_vision_to_learning():
"""Connect FrameStore to HevolveAI's video learning pipeline.
Waits up to 60s for both VisionService and LearningLLMProvider,
then calls start_video_learning().
"""
_logger = logging.getLogger(__name__)
# Fast-fail: if hevolveai isn't installed, learning is impossible — don't wait 60s
import importlib.util
if not importlib.util.find_spec('hevolveai'):
_logger.info("[Wiring] hevolveai not installed — skipping vision-learning wire")
return
for _ in range(60):
svc = get_vision_service()
if svc and _learning_provider:
break
time.sleep(1)
else:
_logger.info(
"[Wiring] Vision or learning not ready after 60s — skipping")
return
try:
if hasattr(_learning_provider, 'start_video_learning'):
_learning_provider.start_video_learning(
frame_store=get_frame_store(),
frame_store_user_id='default',
)
_logger.info(
"[Wiring] FrameStore → HevolveAI video learning connected")
else:
_logger.info(
"[Wiring] LearningProvider has no start_video_learning — skip")
except Exception as e:
_logger.warning(f"[Wiring] FrameStore→learning failed: {e}")
# Boot vision pipeline in background — delayed like learning init
def _delayed_vision_init():
import time
time.sleep(6) # After learning init delay (5s) to stagger torch imports
_init_vision_service()
threading.Thread(
target=_delayed_vision_init, daemon=True,
name='vision_init').start()
# Wire FrameStore to HevolveAI after both subsystems are ready
threading.Thread(
target=_wire_vision_to_learning, daemon=True,
name='vision_learning_wire').start()
# ============================================================================
# Speaker Diarization Pipeline (sidecar subprocess)
# ============================================================================
_diarization_service = None
def _init_diarization_service():
"""Start DiarizationService in standalone mode only.
In bundled mode (Nunba), Nunba owns the lifecycle.
Skips if HEVOLVE_DIARIZATION_URL is already set (external service).
"""
global _diarization_service
if _is_bundled():
logging.getLogger(__name__).info(
"[Diarization] Bundled mode — Nunba owns lifecycle")
return
# If user already configured an external diarization URL, don't start sidecar
if os.environ.get('HEVOLVE_DIARIZATION_URL', '').strip():
logging.getLogger(__name__).info(
"[Diarization] External URL configured — skipping sidecar")
return
try:
from integrations.audio import DiarizationService
_diarization_service = DiarizationService()
_diarization_service.start()
logging.getLogger(__name__).info(
"[Diarization] DiarizationService starting (standalone mode)")
except ImportError:
logging.getLogger(__name__).info(
"[Diarization] audio module not available — disabled")
except Exception as e:
logging.getLogger(__name__).warning(
f"[Diarization] DiarizationService failed to start: {e}")
def get_diarization_service():
"""Get the active DiarizationService instance."""
if _diarization_service is not None:
return _diarization_service
main_mod = sys.modules.get('__main__')
if main_mod:
return getattr(main_mod, '_diarization_service', None)
return None
# Boot diarization sidecar in background
threading.Thread(
target=_init_diarization_service, daemon=True,
name='diarization_init').start()
# task scheduling and logging
class TaskStatus(Enum):
INITIALIZED = "INITIALIZED"
SCHEDULED = "SCHEDULED"
EXECUTING = "EXECUTING"
TIMEOUT = "TIMEOUT"
COMPLETED = "COMPLETED"
ERROR = "ERROR"
class TaskNames(Enum):
GET_ACTION_USER_DETAILS = "GET_ACTION_USER_DETAILS"
GET_TIME_BASED_HISTORY = "GET_TIME_BASED_HISTORY"
ANIMATE_CHARACTER = "ANIMATE_CHARACTER"
STABLE_DIFF = "STABLE_DIFF"
LLAVA = "LLAVA"
CRAWLAB = "CRAWLAB"
USER_ID_RETRIEVER = "USER_ID_RETRIEVER"
# google search API — graceful when GOOGLE_API_KEY is missing (e.g. local-only mode)
try:
search = GoogleSearchAPIWrapper(k=4)
except Exception as e:
logging.getLogger(__name__).info(f"Google Search unavailable (expected in local mode): {e}")
search = None
# constants
# llm = ChatOpenAI(model_name="gpt-3.5-turbo-16k")
# llm = ChatOpenAI(temperature=0, model="gpt-4")
# llm = CustomGPT()
# The above code is creating an instance of the `LLMMathChain` class with an `open_ai_llm` attribute
# initialized with a `ChatOpenAI` object using the model name "gpt-3.5-turbo".
# llm_math = LLMMathChain(ChatOpenAI(model_name="gpt-3.5-turbo"))
# Old: llm_math = LLMMathChain(llm=ChatOpenAI(model_name="gpt-3.5-turbo"))
# get_llm() serves the configured backend (core.autogen_config); no model name here
try:
llm_math = LLMMathChain(llm=get_llm())
except Exception as _llm_math_err:
# NEVER silent. On the 2026-08-07 nightly this except swallowed the
# constructor error, llm_math stayed None for the whole boot, and every
# Tier-1 chat died at `llm_math.run` (AttributeError) while --validate
# stayed green — import succeeds, so the breakage left zero trace until
# a live user traceback. The Calculator tool is optional; chat is not.
# Guarded use sites: get_tools (x2) + tools_func — same `is not None`
# idiom as `chain` below.
llm_math = None
logging.getLogger(__name__).warning(
"LLMMathChain init failed — Calculator tool disabled for this boot "
"(chat continues without it): %s", _llm_math_err)
# llm_math = LLMMathChain(llm= ChatGroq(groq_api_key=groq_api_key,
# model_name = "mixtral-8x7b-32768"))
# llm = ChatGroq(groq_api_key=groq_api_key,
# model_name="llama-3.1-8b-instant", temperature=0.3)
# app.logger.info(llm.invoke("hi how are you?"))
# OpenAPI chain — deprecated (get_openapi_chain removed from langchain)
chain = None
# URL precedence: WAMP_URL env (set by Nunba desktop / regional / central) →
# legacy cloud default. Flat mode (no cloud) sets WAMP_URL to localhost so
# DNS to aws_rasa.hertzai.com is never attempted (#323).
try:
from core.wamp_url import resolve_publish_url as _resolve_wamp_publish
except ImportError: # cx_Freeze defence — never let realtime kill boot
def _resolve_wamp_publish():
return os.environ.get('WAMP_URL') or 'http://localhost:8088/publish'
# The run scripts export ws://…:8088/ws here; this normalises that to the
# HTTP bridge instead of POSTing at a router socket. See core/wamp_url.py.
_wamp_url = _resolve_wamp_publish()
try:
if _CrossbarPub is not None:
client = _CrossbarPub(_wamp_url)
elif _legacy_cb is not None and hasattr(_legacy_cb, 'Client'):
client = _legacy_cb.Client(_wamp_url, timeout=2.0)
else:
# Legacy package may expose Client at .crossbarhttp.Client
# (broken namespace install). Probe before giving up.
_nested = getattr(_legacy_cb, 'crossbarhttp', None) if _legacy_cb else None
client = _nested.Client(_wamp_url, timeout=2.0) if (_nested and hasattr(_nested, 'Client')) else None
except Exception as _cb_err:
client = None
try:
import logging as _logging_cb_warn
_logging_cb_warn.getLogger(__name__).warning(
f"Crossbar HTTP publisher init skipped: {type(_cb_err).__name__}: {_cb_err} — "
f"MessageBus LOCAL+PEERLINK transports remain active.")
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception")
# Create thread pool executor for async Crossbar publishing
crossbar_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='crossbar_publish')
atexit.register(lambda: crossbar_executor.shutdown(wait=False))
_crossbar_client_lock = threading.Lock()
def _http_crossbar_publish(topic: str, payload: str, timeout: float = 2.0):
"""Use the publisher's timeout; never change another socket's defaults.
An unreachable Crossbar is an ordinary state (an offline node), so a
topic's failure is announced once when it starts and once when it ends,
not as a traceback per publish. Tracked per topic (``.failing_topics``,
under the client lock the publish already holds): one global flag let two
workers both warn, and let one always-failing topic beside healthy ones
flip it -- a warning and a "recovered" line per publish.
"""
if client is None:
return
error = None
# The installed crossbarhttp3 distribution exposes crossbarhttp.Client.
# Its timeout and signed-message sequence are mutable client state.
# Serialize only this cloud leg; local/SSE/PeerLink fanout stays async.
with _crossbar_client_lock:
try:
if hasattr(client, 'timeout'):
previous = client.timeout
try:
client.timeout = timeout
client.publish(topic, payload)
finally:
client.timeout = previous
else:
# Preserve alternate SDK compatibility. Its implementation
# owns its timeout; process-global socket mutation is unsafe.
client.publish(topic, payload)
except Exception as e:
error = e
failing = _http_crossbar_publish.__dict__.setdefault(
'failing_topics', set())
log = logging.getLogger(__name__)
if error is None:
if topic in failing:
failing.discard(topic)
log.info("Crossbar publish recovered for %s", topic)
elif topic in failing or len(failing) >= 1000:
log.debug("Crossbar publish still failing for %s: %s", topic, error)
else:
failing.add(topic)
log.warning("Crossbar publish failing for %s (cloud copy not "
"sent; local/PeerLink delivery unaffected): %s: %s -- "
"repeats are logged at debug until it recovers",
topic, type(error).__name__, error)
# Inject HTTP transport into MessageBus (avoids Layer 2 importing Layer 3)
def _inject_http_transport():
try:
from core.peer_link.message_bus import get_message_bus
bus = get_message_bus()
bus.set_http_transport(lambda t, p: crossbar_executor.submit(_http_crossbar_publish, t, p))
except Exception:
logging.getLogger(__name__).exception("_inject_http_transport: swallowed Exception")
_inject_http_transport()
# Topic resolution uses the canonical resolve_legacy_topic() from message_bus.py
# (single source of truth — no duplicate mapping table here)
def publish_async(topic, message, timeout=2.0, skip_crossbar=False):
"""
Publish to all available transports: LOCAL EventBus + PeerLink + Crossbar.
Routes through MessageBus first (local + multi-device delivery),
then falls back to direct HTTP Crossbar for cloud telemetry.
Works fully offline — LOCAL delivery always succeeds.
Also publishes to the confirmation topic (mirrors cloud chatbot.py:publish()).
SIDE-EFFECT — Nunba monkey-patches this at runtime:
Nunba's ``routes/hartos_backend_adapter.py`` wraps this
function in-place to also call ``_capture_thinking(message)``
for every published message. That feeds Nunba's per-request
thinking-trace buffer that the ``/chat`` HTTP response embeds.
Implication for callers: bypassing this function (publishing
directly via ``MessageBus.publish``) silently skips the
capture. If you change the chat-bubble path to call the bus
directly, Nunba's HTTP /chat responses lose their thinking
traces. Either keep going through ``publish_async`` or
migrate Nunba's interceptor to a bus subscriber on
``chat.response`` (tracked in
``memory/project_publish_aop_migration.md`` — the right
long-term shape).
Args:
topic: Crossbar topic to publish to (legacy format). Use
``core.peer_link.message_bus.chat_topic_for(user_id)``
to build per-user chat topic strings instead of inline
f-strings.
message: Message payload (JSON string or dict)
timeout: Maximum time for HTTP Crossbar publish (default: 2.0 seconds)
skip_crossbar: True keeps the message on this node's own legs -- the
MessageBus (LOCAL + the user's own devices over PeerLink) and the
desktop's SSE -- and off the HTTP Crossbar leg, which reaches
central (Reel Studio decision U4(d): a reel push names her
clients). Every stamp above still applies. Nunba's wrapper must
forward it; callers pass it only when True, so a wrapper that
takes (topic, message, timeout) keeps working for everyone else.
Where a chat reply came from: every chat.* envelope but the voice (a
'TTS' payload) leaves here stamped ``served_by`` = local | hive | cloud
(core.constants.canonical_served_by).
The envelope's own key wins; else the thread's reply_from() context
(hartos.threadlocal) -- a context and NOT a keyword, because the wrapper
Nunba installs above takes (topic, message, timeout) only; else the node's
own LLM. The Crossbar HTTP leg below still sends a bare-text message as
the bare text it always was.
"""
# Parse message if JSON string
data = message
if isinstance(message, str):
try:
data = json.loads(message)
except (json.JSONDecodeError, TypeError):
data = {'raw': message}
# The user the PAYLOAD names, read before the bus leg below stamps the
# topic suffix into it: the egress rule compares it with the owner of
# the Crossbar URI (a payload naming nobody on a declared per-user URI
# is that URI's user's).
_claimed_user = data.get('user_id', '') if isinstance(data, dict) else ''
# 1. Route through MessageBus (LOCAL + PEERLINK — always works offline)
try:
from core.peer_link.message_bus import get_message_bus, resolve_legacy_topic
except ImportError:
resolve_legacy_topic = None
bus_topic, user_id = resolve_legacy_topic(topic) if resolve_legacy_topic else (None, '')
if bus_topic:
# Where a chat reply came from: local | hive | cloud, named by the
# ONE rule (core.constants.canonical_served_by). Stamped before the
# bus is touched so the SSE leg below carries it even when the bus
# is down. The envelope's own key, else the dispatch site's
# reply_from() context, else the node's own LLM. Never absent: the
# page's fallback for a missing value is 'cloud', which badged a
# reply the local llama-server wrote "Cloud" (2026-10-04). Local
# imports: test_egress_one_rule exec's this function's source in a
# namespace that holds none of this module's globals.
# A failure here costs the badge, never the delivery.
# Not the voice: a 'TTS' payload is played and never drawn as a
# bubble, and it reaches here on chat.pupit, from the synthesis
# thread that no longer knows the reply's origin -- a hive expert's
# spoken answer was stamped 'local' on the bus envelope and its
# task.confirmation copy (review of 028a8a959).
if (isinstance(data, dict) and bus_topic.startswith('chat.')
and data.get('action') != 'TTS'):
try:
from core.constants import canonical_served_by
from hartos.threadlocal import thread_local_data as _tld_origin
data['served_by'] = canonical_served_by(
data.get('served_by') or _tld_origin.get_served_by(),
data.get('node_tier'))
except Exception as _origin_err:
app.logger.warning(
f"publish_async: served_by not stamped: {_origin_err}")
try:
bus = get_message_bus()
# Ensure user_id is in data for per-user routing
if user_id and isinstance(data, dict):
data.setdefault('user_id', user_id)
# Stamp node_tier on every chat-topic envelope so the SPA's
# formatTier helper (task #146/#148) can render the badge
# without per-callsite plumbing. Closes #153. setdefault
# preserves any explicit value the caller already set —
# this is a strict additive safety net for the audit gap
# task #149 might have missed.
#
# served_by is stamped above, before the bus is touched: the
# dispatch site names it through reply_from() (speculative_
# dispatcher's expert delivery), canonical_served_by defaults it.
if (isinstance(data, dict)
and bus_topic
and bus_topic.startswith('chat.')):
data.setdefault(
'node_tier',
os.environ.get('HEVOLVE_NODE_TIER', 'flat'))
msg_id = bus.publish(
bus_topic, data,
user_id=user_id,
skip_crossbar=True, # We handle Crossbar below directly
)
# Publish confirmation tracking (mirrors cloud chatbot.py:publish())
if bus_topic not in ('task.confirmation', 'task.progress'):
conf_data = dict(data) if isinstance(data, dict) else {'raw': str(data)}
conf_data['confirmation'] = False
conf_data['topic_name'] = topic
conf_data['msg_id'] = msg_id
bus.publish('task.confirmation', conf_data, skip_crossbar=True)
except Exception as e:
app.logger.debug(f"MessageBus publish failed (offline OK): {e}")
# 2. SSE / local-WAMP fan-out for the Nunba desktop SPA.
#
# MessageBus (above) is the in-process EventBus + PeerLink path —
# it does NOT reach the SPA's EventSource subscribers nor the
# embedded wamp_router :8088 that crossbarWorker.js connects to.
# Nunba's main.py exposes ``broadcast_sse_event`` which fans out to
# BOTH legs (publish_local → embedded WAMP topic, plus the SSE
# queue), so calling the canonical helper here gives every publish
# site real-time delivery to the desktop UI without per-call wiring.
#
# Before this leg existed, only TTS bridged to SSE explicitly
# (see TTS publish below); thinking traces, channel events, and
# every other publish_async caller silently dropped on the desktop.
# The publisher abstraction is the canonical home — no parallel
# path at each call site.
# TTS skips this leg — TTS owns its own broadcast_sse_safe call at the
# TTS publish site (search "TTS async: published" below). Routing TTS
# through here in addition was producing two audio playbacks per turn
# (the SPA's request_id-keyed dedup didn't catch them when envelope
# shapes diverged across transports) and racing with the typewriter
# animation. Keep TTS on its dedicated path; this leg covers
# everything else (thinking traces, channel events, capability updates).
if isinstance(data, dict) and data.get('user_id') and data.get('action') != 'TTS':
try:
from core.platform.events import broadcast_sse_safe
# event_type mirrors the canonical chatbot_routes.publish_to_crossbar
# contract (Nunba routes/chatbot_routes.py:733): use payload['type']
# when present, otherwise default to 'message'. The SPA's SSE
# consumer only registers listeners for 'message' (onmessage),
# 'notification', and 'setup_progress'; everything else is routed
# via the local-WAMP leg of broadcast_sse_event (which keys off
# data.get('action') for TTS pupit / chat / social). Using
# 'action' as the SSE event_type would silently drop on SSE
# clients — keep this aligned with the chatbot_routes default.
_evt = data.get('type') if data.get('type') else 'message'
broadcast_sse_safe(_evt, data, user_id=data.get('user_id'))
except Exception as _sse_err:
app.logger.debug(f"SSE fan-out skipped: {_sse_err}")
# 3. HTTP Crossbar for cloud telemetry + legacy mobile (when internet
# available) -- unless the caller keeps this message on the node.
if client is None or skip_crossbar:
return
raw_message = message if isinstance(message, str) else json.dumps(message, default=str)
# Egress (owner ruling 2026-09-26): a topic that is not the message
# user's own reaches other people's subscribers, so it carries the
# scrubbed copy, or nothing when the scrub failed. Asked of the one rule
# (security.edge_privacy.crossbar_egress_copy) the MessageBus Crossbar
# leg asks; the user's own topic still goes byte-identical.
_wire, _wire_is_json = message, not isinstance(message, str)
if isinstance(message, str):
try:
_wire, _wire_is_json = json.loads(message), True
except (json.JSONDecodeError, TypeError):
pass
try:
from security.edge_privacy import crossbar_egress_copy
_out = crossbar_egress_copy(topic, _wire, _claimed_user)
except ImportError as _ep_err:
app.logger.warning(f"Egress rule unavailable ({_ep_err}); not "
f"publishing {topic} to Crossbar")
return
if _out is None:
return
if _out is not _wire:
raw_message = json.dumps(_out, default=str) if _wire_is_json else _out
crossbar_executor.submit(_http_crossbar_publish, topic, raw_message, timeout)
def _get_dynamic_capability_prompt() -> str:
"""Auto-generate capability context for the LLM based on live node state.
- Bundled (Nunba): includes audio synthesis instructions + dynamic capabilities
- Text-only (Hevolve web/channels): only includes non-audio capabilities
- Empty string if nothing special is available (zero prompt bloat)
New models/services appear automatically — no hardcoding per model.
"""
parts = []
# Audio mode context (Nunba only — verify TTS is actually available)
if _is_bundled() if callable(_is_bundled) else _is_bundled:
_tts_ok = False
try:
from tts.tts_engine import get_tts_engine
_tts_ok = get_tts_engine() is not None
except Exception:
logging.getLogger(__name__).exception("_get_dynamic_capability_prompt: swallowed Exception")
if _tts_ok:
parts.append(
'Your text output is auto-synthesized as audio. You can freely '
'mix languages (the system detects scripts and routes each to '
'the right TTS engine). For rich audio, embed tags: '
'mood for music, '
'lyrics for singing voice.')
# Dynamic capabilities from orchestrator (any mode)
try:
from integrations.service_tools.model_orchestrator import get_orchestrator
cap_prompt = get_orchestrator().capability_prompt()
if cap_prompt:
parts.append(cap_prompt)
except Exception:
logging.getLogger(__name__).exception("_get_dynamic_capability_prompt: swallowed Exception")
return '\n'.join(parts)
# create prompt
def create_prompt(tools):
# No per-query classification here — the 30s cache + 1.5s hot-path
# budget inside get_user_context are the only speed wins. Repeat
# constructions within the TTL window never re-hit the backend.
user_details, actions = get_action_user_details(
user_id=thread_local_data.get_user_id())
# Build dynamic identity from the ACTIVE agent's config (prompts/{id}.json),
# loaded by the request's prompt_id via the mtime-cached, self-refreshing
# core.cache_loaders.load_agent_config. Replaces a dead threadlocal stub
# (get_agent_config was never defined → hasattr always False → always None),
# which silently dropped the agent's persona from casual replies even though
# the full-agent path injects it via build_personality_prompt. Fenced: the
# hot path never breaks if the load hiccups.
try:
from core.cache_loaders import load_agent_config as _load_agent_config
_active_agent_config = _load_agent_config(thread_local_data.get_prompt_id())
except Exception:
_active_agent_config = None
_owner_name = extract_owner_name(user_details)
_dynamic_identity = build_identity_prompt(_active_agent_config, _owner_name, user_details)
prefix = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
{_dynamic_identity}
Consider the consequences of each response you provide.
Your answers must be meaningful and delivered as quickly as possible.
Never refer to the user as a human or yourself as mere AI.
Your response should not be more than 200 words.
{get_cultural_prompt_compact()}
User details:
{user_details}
You can help with anything — answering questions, teaching, coding, research, creative writing, data analysis, building agents, brainstorming, planning, and more.
Your expertise draws from various knowledge sources like books, websites, and white papers. Your responses will be conveyed to the user through a video, using an avatar and text-to-speech technology, and can be translated into various languages.
Consider the user's location, time and context of previous dialogues with time to create a proper prompt for tools and follow up in-context questions. You have the ability to see using Visual_Context_Camera tool.
If your response contains abbreviated words, please separate them with spaces, like T T S.
{_get_dynamic_capability_prompt()}
These are all the actions that the user has performed up to now:
{actions}
Conversation History:
"""
suffix = """
Only if this above conversation history is not sufficient to fulfill the user's request then use below FULL_HISTORY tool. Important: If results can be accomplished with above information skip tools section and move to format instructions.
TOOLS
------
Assistant can use tools to look up information that may be helpful in answering the user's
question. The tools you can use are:
{{tools}}
{format_instructions}
always create parsable output.
Here is the User and AI conversation in reverse chronological order:
USER'S INPUT:
-------------
Latest USER'S INPUT For which you need to respond (consult recent history only when needed for more context): {{{{input}}}}
"""
prompt = ConversationalChatAgent.create_prompt(
tools,
system_message=prefix,
human_message=suffix,
input_variables=["input", "agent_scratchpad", "chat_history"]
)
# prompt_string = prompt.render()
# prompt.rende
return prompt
def _suggest_share_worthy_content(input_text: str) -> str:
"""Tool handler: find high-engagement, under-shared posts the user could share.
Queries the social DB for posts with strong engagement (upvotes > 5,
comments > 3) but few share links (< 3), then returns a human-friendly
suggestion.
"""
try:
from integrations.social.models import get_db, Post, ShareableLink
from sqlalchemy import func, outerjoin
db = get_db()
try:
# Subquery: count share links per post
share_counts = (
db.query(
ShareableLink.resource_id,
func.count(ShareableLink.id).label('link_count'),
)
.filter(ShareableLink.resource_type == 'post')
.group_by(ShareableLink.resource_id)
.subquery()
)
# Main query: high engagement, low shares, not deleted/hidden
posts = (
db.query(Post, share_counts.c.link_count)
.outerjoin(share_counts, Post.id == share_counts.c.resource_id)
.filter(
Post.is_deleted == False,
Post.is_hidden == False,
Post.upvotes > 5,
Post.comment_count > 3,
)
.filter(
(share_counts.c.link_count == None) | # noqa: E711
(share_counts.c.link_count < 3)
)
.order_by(Post.score.desc())
.limit(3)
.all()
)
if not posts:
return ("No under-shared high-engagement content found right now. "
"Keep creating great posts and the community will notice!")
suggestions = []
for post, link_count in posts:
title = (post.title or post.content or '')[:80].strip()
shares = link_count or 0
suggestions.append(
f"- \"{title}\" ({post.upvotes} upvotes, "
f"{post.comment_count} comments, only {shares} shares) "
f"[post_id: {post.id}]"
)
header = ("These posts are resonating with the community but haven't "
"been shared much yet. Consider sharing them with your network:\n")
return header + "\n".join(suggestions)
finally:
db.close()
except Exception as e:
logging.debug(f"Suggest_Share_Worthy_Content failed: {e}")
return f"Could not fetch share-worthy content right now: {e}"
def _observe_user_experience(input_text: str) -> str:
"""Record a user experience observation for self-improvement."""
try:
import json as _json
data = _json.loads(input_text)
event = data.get('event', 'unknown')
page = data.get('page', '')
duration_ms = data.get('duration_ms', 0)
outcome = data.get('outcome', '')
observation = f"User {event} on {page} ({duration_ms}ms): {outcome}"
# Use MemoryGraph if available
try:
user_id = thread_local_data.get_user_id()
prompt_id = thread_local_data.get_prompt_id()
graph = _get_or_create_graph(user_id, prompt_id)
if graph:
session_id = f"{user_id}_{prompt_id}" if prompt_id else str(user_id)
memory_id = graph.register(
content=observation,
metadata={
'memory_type': 'observation',
'source_agent': 'agent',
'session_id': session_id,
'page': page,
'event': event,
},
context_snapshot=f"UX observation during session {session_id}",
)
return f"Observation recorded (id: {memory_id}): {observation}"
except Exception:
logging.getLogger(__name__).exception("_observe_user_experience: swallowed Exception")
return f"Observation noted: {observation}"
except Exception:
return f"Observation noted: {input_text}"
def _self_critique_and_enhance(input_text: str) -> str:
"""Review past suggestions and outcomes to improve future behavior."""
try:
user_id = thread_local_data.get_user_id()
prompt_id = thread_local_data.get_prompt_id()
graph = _get_or_create_graph(user_id, prompt_id)
if not graph:
return "Self-critique unavailable: no memory graph for this session."
session_id = f"{user_id}_{prompt_id}" if prompt_id else str(user_id)
# Recall past suggestions and observations
suggestions = graph.recall(input_text or 'suggestions made outcomes', mode='semantic', top_k=10)
observations = graph.recall('user experience observation', mode='semantic', top_k=10)
if not suggestions and not observations:
return "No past interactions to critique yet. Will observe and learn."
# Format findings for agent reasoning
critique = "Self-critique findings:\n"
if suggestions:
critique += f"Past suggestions ({len(suggestions)}):\n"
for s in suggestions[:5]:
critique += f" - {s.content[:100]}\n"
if observations:
critique += f"User observations ({len(observations)}):\n"
for o in observations[:5]:
critique += f" - {o.content[:100]}\n"
# Store the critique itself as an insight
insight = f"Self-critique on: {input_text}"
graph.register(
content=insight,
metadata={
'memory_type': 'insight',
'source_agent': 'agent',
'session_id': session_id,
'type': 'self_critique',
},
context_snapshot=f"Self-critique during session {session_id}",
)
return critique
except Exception as e:
return f"Self-critique unavailable: {str(e)}"
# Single source of truth for the Create_Agent tool description.
# Previously duplicated at lines ~4580 and ~5042 — a DRY violation
# that meant fixing the trigger heuristic required two edits and
# allowed the two copies to drift.
#
# The wording is intentionally restrictive (PERSISTENT + MULTI-STEP)
# because the prior version triggered for any "create / build / set
# up / deploy" verb — including code-generation requests like "write
# a palindrome function" that should be answered inline by the chat
# LLM, not routed into the create_recipe peer-review pipeline. The
# 2026-05-28 palindrome cycle (request_id c4b09d1f-…) is the
# repro: 4 review rounds + 1 escalation = 10 thinking-events, then
# the force-approved plan called execute_windows_or_android_command
# 9 times trying to type a palindrome check into a terminal.
_CREATE_AGENT_TOOL_DESCRIPTION = (
"Use this tool ONLY when the user wants a PERSISTENT MULTI-STEP agent "
"that runs over time or across messages — e.g. 'set up a daily news "
"summarizer', 'create a trading bot that posts updates', 'build an "
"agent that monitors my inbox and replies to customer questions'.\n\n"
"Do NOT use this tool for any of:\n"
" - One-shot code generation: 'write a palindrome function', "
" 'write a quicksort', 'show me a Fibonacci implementation'. "
" Just write the code in your chat reply.\n"
" - Single questions / explanations: 'what is X', 'explain Y', "
" 'how does Z work'. Answer directly.\n"
" - Math / logic computations: palindrome checks, factorials, "
" string reversal, search algorithms — compute the answer "
" in your response, don't spawn an agent.\n"
" - Direct documentation, refactoring, code review of pasted text.\n\n"
"Input should be the description of what the persistent agent should do.\n"
"If the user uses keywords like 'automatically', 'autonomous', "
"'do it for me', 'handle it', 'just create it', include them in your "
"input so the downstream pipeline runs in autonomous mode."
)
def _without_agent_work_tools(tools):
"""``tools`` minus the agent-work pair (core.constants.AGENT_WORK_TOOL_NAMES:
Create_Agent, an interview or an autonomous build saved under the
caller's id, and Agentic_Router, a Plan Mode consent question); every
other tool keeps its place. Objects without a ``name`` are kept. Both
get_tools branches offer the pair to a person; a metered Hive API call
must not get it, since its caller is a program that can answer none of
those and the tool's output would be the billed reply (task #120; the
review of 7e7ce19bc drove it). The ONE filter both branches apply."""
return [t for t in tools if getattr(t, 'name', None) not in AGENT_WORK_TOOL_NAMES]
def _handle_create_agent_tool(input_text):
"""Tool handler: LLM decided user wants to create an agent.
Called by the LangChain agent when it detects agent creation intent.
Sets thread-local flags that the /chat handler checks after get_ans() returns.
"""
lower = input_text.lower()
autonomous = any(w in lower for w in [
'autonomous', 'automatic', 'automatically', 'do it for me',
'handle it', 'just create', 'create it yourself', 'auto',
])
thread_local_data.set_creation_requested(description=input_text, autonomous=autonomous)
if autonomous:
return f"Agent creation initiated autonomously for: {input_text}. I will set up the agent creation workflow and handle all the details automatically."
return f"Agent creation initiated for: {input_text}. I will set up the agent creation workflow. Let me gather the required details."
# Signals from reuse agent that suggest creating a new agent
_RESPONSE_CREATION_SIGNALS = [
'need a new agent', 'create a new agent', 'requires a different agent',
'beyond my capabilities', 'specialized agent', 'need a specialized',
'suggest creating', 'recommend creating a new',
]
def _response_signals_creation(response_text):
"""Check if the reuse agent's response suggests creating a new agent."""
lower = response_text.lower()
return any(signal in lower for signal in _RESPONSE_CREATION_SIGNALS)
# ─── Perception Watchers (Future TTL) ───────────────────────────────────
_active_watchers = {} # user_id -> [{trigger_id, expires_at, condition, action, modality, callback}]
def _handle_visual_watcher_tool(input_text):
"""Register a visual/audio watcher trigger with TTL.
Input format: 'CONDITION: | ACTION: | TTL: '
Optional: '| MODALITY: visual|audio|both' (defaults to visual)
"""
import re
import time as _time
parts = {}
for segment in input_text.split('|'):
segment = segment.strip()
if ':' in segment:
key, val = segment.split(':', 1)
parts[key.strip().upper()] = val.strip()
condition = parts.get('CONDITION', input_text)
action_text = parts.get('ACTION', 'notify user')
ttl_minutes = int(parts.get('TTL', '30'))
modality = parts.get('MODALITY', 'visual').lower()
user_id = thread_local_data.get_user_id() or 'default'
trigger_id = f'watcher_{user_id}_{int(_time.time())}'
expires_at = _time.time() + (ttl_minutes * 60)
def _on_trigger(description, **kwargs):
"""Callback fired when visual trigger matches."""
try:
from core.platform.events import emit_event
emit_event('tts.speak', {'user_id': user_id, 'text': action_text})
emit_event('perception.watcher.fired', {
'user_id': user_id, 'condition': condition,
'action': action_text, 'trigger_id': trigger_id,
})
except Exception:
logging.getLogger(__name__).exception("_on_trigger: swallowed Exception")
# Register with VisionService for visual watchers
if modality in ('visual', 'both'):
try:
from integrations.vision.vision_service import VisionService
from core.platform.registry import ServiceRegistry
vs = ServiceRegistry.get('VisionService')
if vs:
condition_words = [w for w in condition.lower().split() if len(w) > 3]
vs.register_visual_trigger(
channel='camera', callback=_on_trigger,
keywords=condition_words, cooldown_seconds=5,
name=trigger_id,
)
except Exception:
logging.getLogger(__name__).exception("_handle_visual_watcher_tool: swallowed Exception")
watcher_entry = {
'trigger_id': trigger_id, 'expires_at': expires_at,
'condition': condition, 'action': action_text,
'modality': modality, 'callback': _on_trigger,
}
if user_id not in _active_watchers:
_active_watchers[user_id] = []
_active_watchers[user_id].append(watcher_entry)
return (
f"Watcher registered: watching for '{condition}' → will '{action_text}'. "
f"TTL: {ttl_minutes} minutes. Modality: {modality}."
)
def _evaluate_audio_watchers(user_id, transcript):
"""LLM-powered evaluation of audio watchers against transcript."""
import time as _time
watchers = [w for w in _active_watchers.get(user_id, [])
if w['modality'] in ('audio', 'both') and _time.time() < w['expires_at']]
if not watchers:
return
conditions = "\n".join(f"- Watcher {i+1}: {w['condition']}" for i, w in enumerate(watchers))
try:
llm = get_llm(temperature=0.1, max_tokens=200)
result = llm.invoke(
f"The user just said: \"{transcript}\"\n\n"
f"Active watchers:\n{conditions}\n\n"
f"Which watcher conditions (if any) are semantically triggered by what the user said? "
f"Return ONLY a JSON array of triggered watcher numbers, e.g. [1, 3]. "
f"Return [] if none match."
)
import re as _re
text = (result.content if hasattr(result, 'content') else str(result)).strip()
match = _re.search(r'\[.*?\]', text)
if match:
triggered = json.loads(match.group())
for idx in triggered:
if 1 <= idx <= len(watchers):
w = watchers[idx - 1]
if w.get('callback'):
w['callback'](transcript)
except Exception as e:
app.logger.debug(f"Audio watcher eval failed: {e}")
def _push_workflow_flowchart(user_id, prompt_id, request_id=None):
"""Push recipe JSON to frontend via Crossbar for interactive flowchart rendering."""
try:
recipe_path = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
if not os.path.isfile(recipe_path):
return
with open(recipe_path) as f:
recipe_data = json.load(f)
crossbar_message = {
"text": ["Agent workflow ready"],
"priority": 50,
"action": "WorkflowFlowchart",
"recipe": recipe_data,
"request_id": request_id or "0",
"prompt_id": prompt_id,
"historical_request_id": [],
"options": [], "newoptions": [],
}
from core.peer_link.message_bus import chat_topic_for
publish_async(chat_topic_for(user_id), json.dumps(crossbar_message))
except Exception:
logging.getLogger(__name__).exception("_push_workflow_flowchart: swallowed Exception")
def _request_consent(action: str, label: str, input_text: str) -> str:
"""Ask this computer's owner for a capability, through ConsentService.
Same canonical path as every other ask -- integrations.vlm.safety
.computer_control_block, vision_service's capture loop, whisper_tool,
social/auth, ai_governance, hive_guardrails -- so the ask is a
UserConsent row the owner can see on the privacy page and revoke, it
carries an immutable audit entry, and it leaves as `consent.request`,
the one payload type every surface renders.
It used to push a bare `{'type': 'approval'}` component through
LiquidUIService with a broadcast_sse_safe fallback and touch no database
at all. That meant: nothing to revoke, nothing on the privacy page, and
a payload only AgentOverlay knows -- so the floating companion, which
exists precisely for when Nunba is NOT the window in front, dropped every
camera and screen ask (#863). The canonical emit that fixed the sibling
asks landed upstream on 2026-08-26 (e991309da); this path never migrated.
Whose permission: the owner of this desktop (HEVOLVE_OWNER_USER_ID, which
Nunba exports at boot), never the caller -- the camera and the screen are
this machine's, and a remote user must not be asked to lend them. Same
rule and same fail-closed answer as computer_control_block and
/visual_agent (#66). Which agent is asking comes from the request's own
prompt id, not the capability label the tool wrapper passes.
"""
# The ask itself is consent_service.ask_owner, shared with the camera
# tool's own ask (hartos.helper), so the two cannot drift apart.
from integrations.social.consent_service import (
ASK_FAILED, NO_OWNER, NOT_HERE, OWNER_ALLOWED, OWNER_SAID_NO,
ask_owner, central_lacks, consent_type_for_action)
log = logging.getLogger(__name__)
consent_type = consent_type_for_action(action)
if consent_type is None:
log.error('_request_consent: %r maps to no consent type', action)
return (f"Could not request {label.lower()} access — this computer "
f"records no permission for '{action}'.")
outcome = ask_owner(consent_type, f'{label} access needed: {input_text}'.strip(),
thread_local_data.get_prompt_id())
if outcome == NOT_HERE:
return f"Not asked: central has no {central_lacks(consent_type)}."
if outcome == NO_OWNER:
log.warning('%s consent not asked: no owner identity '
'(HEVOLVE_OWNER_USER_ID is not set)', label)
return (f"Not asked: nobody is signed in on this computer who could "
f"allow {label.lower()} access.")
if outcome == OWNER_ALLOWED:
return f"{label} access is already allowed — go ahead."
if outcome == OWNER_SAID_NO:
return (f"The owner of this computer said no to "
f"{label.lower()} access.")
if outcome == ASK_FAILED:
return (f"Could not request {label.lower()} access — the permission "
f"system is unavailable.")
return (f"{label} access request sent to the owner of this computer. "
f"Waiting for their answer.")
def _request_capability_consent(input_text: str) -> str:
return _request_consent('enable_camera', 'Camera', input_text)
def _request_screen_consent(input_text: str) -> str:
return _request_consent('enable_screen', 'Screen', input_text)
def _handle_screenshot_tool(input_text: str) -> str:
"""Take screenshot and describe using VLM."""
# AI senses kill-switch: refuse to grab the screen when the human has cut
# the AI's 'screen' sense — "humans are always in control", enforced at the
# grab (not advisory). Mirrors the mic gate at /api/voice.
try:
from core.ai_sensing import allowed
if not allowed('screen'):
return "Screen access is turned off by the user (AI senses kill-switch)."
except Exception:
logging.getLogger(__name__).exception("_handle_screenshot_tool: swallowed Exception")
try:
from PIL import ImageGrab
import base64, io
screenshot = ImageGrab.grab()
buf = io.BytesIO()
from integrations.vlm.local_computer_tool import VLM_IMG_W, VLM_IMG_H
screenshot.resize((VLM_IMG_W, VLM_IMG_H)).save(buf, 'JPEG', quality=50)
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
resp = pooled_post(
GPT_API,
json={
'model': LLM_MODEL_NAME,
'messages': [{'role': 'user', 'content': [
{'type': 'text', 'text': f'Describe what you see on this screen. Question: {input_text}'},
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{b64}'}}
]}],
'max_tokens': 300,
},
headers=LLM_AUTH_HEADERS,
timeout=15,
)
if resp.status_code == 200:
return resp.json().get('choices', [{}])[0].get('message', {}).get('content', 'Could not describe screen.')
return "Screenshot taken but VLM unavailable for description."
except Exception as e:
return f"Screenshot error: {str(e)[:200]}"
def _handle_shell_command_tool(input_text: str) -> str:
"""Execute a shell / PowerShell / bash command on the user's computer
and return its stdout+stderr. This is the FAST path for anything that
can be done programmatically — `notepad file.txt` is one command vs
the 18-iteration VLM loop Computer_Action would do for the same task.
Safety:
- Input is NFKC-normalized + lowercased before the denylist regex
runs, so unicode lookalikes (full-width 'rm', cyrillic 'р') can't
slip past patterns written in ASCII.
- Hard-denylist of clearly destructive patterns (format, rm -rf /,
shred, dd if=/dev/, mkfs, del /s C:\\ etc.). Denylisted commands
return an explanation without running.
- 30-second timeout. Longer-running work should use Execute_Coding_Task.
- Output truncated to 4000 characters so a `tree /` style flood can't
blow the LLM context budget.
- Logged via _with_tool_logging on the Tool wrapper so every
invocation is auditable.
Input formats:
- 'notepad' / 'calc.exe' / 'explorer C:\\Users' — plain command string
- 'powershell: Get-Process | Select-Object -First 5' — explicit shell
- 'bash: ls -la ~ | head' — explicit shell
On Windows we use cmd.exe by default; 'powershell:' prefix switches to
pwsh. On Unix we use /bin/sh; 'bash:' prefix switches to bash.
"""
import re as _re_shell
import unicodedata as _unicodedata
if not input_text or not isinstance(input_text, str):
return "Shell_Command: empty input. Pass a command string like 'notepad file.txt'."
text = input_text.strip()
# Explicit shell selector, in EITHER spelling. Both resolve HERE, into
# the one argv builder below — a second dispatch path is exactly the
# drift this closes.
#
# colon 'powershell: ' this tool's own documented form
# native 'powershell -Command ""' what a model actually writes
#
# D73, live-measured 2026-09-11 (agent 89091774807, 09:55:19). Only the
# colon form was understood, so the native form fell through to the
# Windows default and ran nested as
# `cmd /c powershell -Command "..."`. Reproduced byte-exact:
#
# rc = 0
# stdout = 'Get-PSDrive -Name C | Select-Object -ExpandProperty FreeGB'
# stderr = ''
#
# Exit 0 with the command echoed back as its own output — worse than an
# error, because every honesty gate downstream reads it as SUCCESS. The
# VLM concluded "The output shows 'FreeGB : 100.0'" (a figure present
# nowhere in that output), reported exit_reason=done, FAB-GUARD passed
# the action because the tool HAD executed, and the user was told the
# machine had 127.4 GB free against a real 6.32 GB.
#
# Dispatched directly the same command returns rc=1 with 'Property
# "FreeGB" cannot be found' — a failure the model can act on.
shell_override = None
# 'shell:' / 'terminal:' name the DEFAULT shell: the VLM loop's models
# write the tool's name in front of the command ("shell: dir", measured
# 2026-10-04 in the daemon's runs), and cmd.exe answered "'shell:' is not
# recognized" to every one. They select nothing (the argv builder below
# falls to its default for any other spelling) and only lose the prefix.
m = _re_shell.match(r'^(powershell|pwsh|bash|sh|cmd|shell|terminal)\s*:\s*(.+)$',
text, _re_shell.IGNORECASE)
if m:
shell_override = m.group(1).lower()
text = m.group(2).strip()
# Having named no shell, a 'shell:' / 'terminal:' command is read exactly
# like an unprefixed one, the native form included. Stripping the prefix
# and stopping there ran `shell: powershell -Command "Get-Date -Format
# yyyy"` as `cmd /c powershell -Command "..."`: exit 0 with the command
# text as its output, the D73 false success below (review of 0cfa782cf,
# measured on the MSI 2026-10-05; before that commit the same input failed
# honestly with exit 1).
if shell_override in (None, 'shell', 'terminal'):
# -enc / -EncodedCommand is deliberately NOT accepted as a selector:
# it is obfuscation, and leaving it unstripped keeps it in front of
# the denylist pattern that blocks it. Only the execute-this-string
# switches (-Command / -c / /c) are consumed.
m = _re_shell.match(
r'^(powershell|pwsh|bash|sh|cmd)(?:\.exe)?\s+'
r'(?:-(?:NoProfile|NonInteractive|NoLogo|ExecutionPolicy\s+\S+|'
r'WindowStyle\s+\S+)\s+)*'
r'(?:-Command|-c|/c)\s+(.+)$',
text, _re_shell.IGNORECASE)
if m:
shell_override = m.group(1).lower()
inner = m.group(2).strip()
# Strip ONE matching pair of wrapping quotes, as the shell would.
if len(inner) >= 2 and inner[0] == inner[-1] and inner[0] in '"\'':
inner = inner[1:-1].strip()
# The denylist below now sees the INNER command. Its patterns are
# substring searches, so destructive text is still caught — pinned
# by TestNativeShellInvocationIsUnderstood's denylist cases.
text = inner or text
# --- Denylist of destructive patterns (case-insensitive, conservative)
_DENY_PATTERNS = [
r'\bformat\s+[a-z]:', # format C:
r'\brm\s+-rf\s+/', # rm -rf / (anywhere in string)
r'\brm\s+-rf\s+--no-preserve-root',
r'\brm\s+-rf\s+~', # rm -rf ~
r'\bdel\s+/s\s+/q\s+[a-z]:\\', # del /s /q C:\
r'\brmdir\s+/s\s+/q\s+[a-z]:\\',
r'\bmkfs\.', # mkfs.*
r'\bdd\s+if=/dev/(zero|random|urandom)\s+of=/dev/[sh]d',
r'\bshred\b',
r':\(\)\s*\{\s*:\|:&\s*\}\s*;:', # fork bomb
r'\bhalt\b', r'\bpoweroff\b',
r'\breboot\b', # conservative — often benign but blocking
r'\bshutdown\s+-[srh]',
r'\breset-computermachinepassword', # dangerous ps cmdlet (lowercased)
r'\bformat-volume',
r'\bremove-item\s+-recurse\s+-force\s+[a-z]:\\',
# Chaining patterns — ethical hacker flagged that 'echo hi && rm -rf /'
# bypassed the old $-anchored pattern. These catch destructive commands
# anywhere in a chained expression (&&, ;, |, ||, backtick, $()).
r'\bmv\s+/\s+/dev/null', # mv / /dev/null
r'\bchmod\s+-[rR]\s+000\s+/', # chmod -R 000 /
r'\bfind\s+/\s+-delete', # find / -delete
r'\bcurl\s+.*\|\s*bash', # curl ... | bash (pipe to shell)
r'\bwget\s+.*\|\s*bash', # wget ... | bash
# Interpreter wrappers — ethical hacker found python -c / perl -e / ruby -e
# bypass all above patterns by wrapping destructive code inside an interpreter.
r'\bpython[23]?\s+-c\s', # python -c "os.system(...)"
r'\bperl\s+-e\s', # perl -e "system(...)"
r'\bruby\s+-e\s', # ruby -e "system(...)"
r'\bnode\s+-e\s', # node -e "require('child_process')..."
r'\bpowershell.*-enc', # powershell -EncodedCommand (obfuscation)
]
# NFKC maps full-width / compatibility chars to their ASCII equivalents
# ('rm' → 'rm', '>' → '>'), defeating the common homoglyph bypass.
# We also strip zero-width characters (ZWJ, ZWNJ, BOM) that would
# otherwise split matches like 'r\u200cm'. Strip control chars too.
#
# IMPORTANT: we normalize for the denylist CHECK only. The command
# actually executed is still the raw user text — so a legitimate
# filename with ligatures ('file.txt') or full-width chars still
# resolves to the real file on disk. If the raw text happened to
# contain a homoglyph version of a denylisted token, the denylist
# sees it after normalization and blocks before execution.
_normalized = _unicodedata.normalize('NFKC', text)
_normalized = ''.join(
ch for ch in _normalized
if _unicodedata.category(ch) not in ('Cf', 'Cc') or ch in ('\t', '\n')
)
text_l = _normalized.lower()
for pat in _DENY_PATTERNS:
if _re_shell.search(pat, text_l):
return (
f"Shell_Command refused: the command matches a destructive "
f"pattern ('{pat}') and is blocked by the built-in safety list. "
f"If you really need this, ask the user to run it manually."
)
# --- The shared hard-deny policy every computer-use dispatcher calls
# (power/reset/erase, and stopping the assistant's own processes, #877).
# The LangChain Shell_Command tool reached run_bounded without it.
from integrations.vlm.safety import computer_operation_refusal
_refusal = computer_operation_refusal({'command': text})
if _refusal is not None:
return f"Shell_Command refused: {_refusal}"
# --- The owner's permission: the same computer_control consent the VLM
# loop checks (integrations.vlm.safety.computer_control_block). After
# the denylist on purpose, so a destructive command is refused without
# asking anyone. Inside a VLM run this thread's prompt_id is the run's
# agent (run_local_agentic_loop sets it); for the LangChain tool it is
# the chat turn's agent, stamped by the /chat handler.
from integrations.vlm.safety import computer_control_block
_refusal = computer_control_block(thread_local_data.get_prompt_id())
if _refusal is not None:
return f"Shell_Command not run: {_refusal}"
# --- Choose shell + argv
if sys.platform == 'win32':
if shell_override in ('powershell', 'pwsh'):
argv = ['powershell', '-NoProfile', '-Command', text]
elif shell_override in ('bash', 'sh'):
argv = ['bash', '-c', text]
else:
argv = ['cmd', '/c', text]
else:
if shell_override in ('powershell', 'pwsh'):
argv = ['pwsh', '-NoProfile', '-Command', text]
elif shell_override == 'bash':
argv = ['bash', '-c', text]
else:
argv = ['/bin/sh', '-c', text]
# Bounded through core.subprocess_safe — a plain subprocess.run(timeout=30)
# CANNOT enforce that 30s on Windows. When the deadline fires, run() kills
# the direct child and then calls process.communicate() a SECOND time with
# NO timeout (CPython subprocess.py:559) to drain the pipes. kill() does
# not reach a grandchild, and a surviving grandchild holds the inherited
# stdout/stderr WRITE handles open, so the reader threads never see EOF and
# that drain blocks forever — the `except TimeoutExpired` below could never
# be reached. Measured live 2026-09-09: agent 33323830039's reuse turn sat
# in exactly this frame for 21 minutes while llama-server was idle,
# wedging that user's whole chat turn (thread dump, D35).
#
# run_bounded kills AND explicitly closes the parent-side handles, which is
# what actually releases the readers; its boundedness is already proven by
# tests/unit/test_subprocess_safe.py::TestRunProbeBoundedness. It also
# pins stdin=DEVNULL, so a command that unexpectedly reads stdin gets EOF
# instead of hanging — a second way this could freeze, also closed.
#
# argv still routes through the chosen shell, and run_bounded never passes
# shell=True, so the string is not double-parsed. no_window_kwargs() is
# applied INSIDE run_bounded; repeating it here would be a second copy of
# that decision.
# Announce it like any other computer-use step, instead of poking the
# ribbon directly. Two things were wrong with the bare poke: the floating
# companion window, which reads the computer_use.update topic, never heard
# shell commands at ALL; and its `finally` hide took the ribbon down even
# when this ran INSIDE a VLM run that was still driving the machine.
# current_run joins that enclosing run when there is one (this tool
# reaches here on the loop's own thread) and opens a run of its own when
# there is not, so neither case needs a branch here.
from integrations.vlm.activity_stream import current_run
_caption = f"Shell command: {text[:60]}"
_run = current_run(user_id=thread_local_data.get_user_id() or '',
prompt_id=thread_local_data.get_prompt_id() or '')
_run.step(iteration=1, action='shell', phase='executing', caption=_caption)
_phase, _err = 'completed', ''
# Inside a computer-use run the command runs in the run's workspace
# (hartos.threadlocal workspace, set by integrations.vlm.local_loop),
# where the run's file actions resolve too: the prompt's route for a
# refused python -c is "write_file the script, then shell it".
# Measured 2026-10-05: every shell step ran in the process cwd, the
# install folder. Only an existing folder is handed to the OS; a missing
# one would fail the spawn and read as "interpreter not found".
# Outside a run (the chat's Shell_Command) nothing changes.
_bounded_kw = {}
_workspace = thread_local_data.get_workspace()
if isinstance(_workspace, str) and _workspace and os.path.isdir(_workspace):
_bounded_kw['cwd'] = _workspace
try:
proc = run_bounded(argv, timeout=SHELL_COMMAND_TIMEOUT_S, **_bounded_kw)
except FileNotFoundError as e:
_phase, _err = 'failed', f'interpreter not found — {e}'
return f"Shell_Command: interpreter not found — {e}"
except Exception as e:
_phase, _err = 'failed', f'{type(e).__name__}: {str(e)[:200]}'
return f"Shell_Command error: {type(e).__name__}: {str(e)[:200]}"
finally:
_run.step(iteration=1, action='shell', phase=_phase,
caption=_caption, error=_err)
# Only closes a run this call opened; inside a VLM run the loop still
# owns the ribbon and the ledger task.
_run.finish(exit_reason='done' if _phase == 'completed' else 'action_error',
iteration=1, action='shell', error=_err)
# run_bounded never raises TimeoutExpired — it reports the kill this way.
if proc.timed_out:
return (
f"Shell_Command timed out after {SHELL_COMMAND_TIMEOUT_S}s. "
"For long-running work use "
"Execute_Coding_Task instead, which has a longer budget."
)
out = (proc.stdout or '').strip()
err = (proc.stderr or '').strip()
rc = proc.returncode
# Truncate combined output to 4000 chars max
combined = []
if out:
combined.append(out[:2000])
if err:
combined.append(f"[stderr]\n{err[:2000]}")
body = '\n'.join(combined) if combined else '(no output)'
header = f"Exit code: {rc}"
if len(body) > 4000:
body = body[:4000] + '\n...(truncated)'
return f"{header}\n{body}"
def _handle_navigate_app_tool(input_text: str) -> str:
"""Resolve a spoken destination to a Nunba SPA route and attach a
structured ui_action the frontend LiquidActionBar can render.
The heavy lifting lives in integrations.ui_actions.navigate_tool so
autogen and LangChain share exactly one code path. Thread-local
ui_actions are read by the /chat handler on the way out."""
try:
from integrations.ui_actions import navigate_tool as _nt
role = getattr(thread_local_data, 'get_user_role', lambda: 'flat')() or 'flat'
return _nt.handle_navigate_app(
input_text, user_role=role, thread_local=thread_local_data,
)
except ImportError:
return "Navigate_App is not available on this node."
except Exception as e:
return f"Navigate_App error: {str(e)[:200]}"
def _handle_computer_action_tool(input_text: str) -> str:
"""Execute GUI action on user's computer via VLM agentic loop.
Reuses the same pipeline as execute_windows_or_android_command (autogen):
vlm_adapter → local_loop → point_and_act → pyautogui.
"""
user_id = thread_local_data.get_user_id()
prompt_id = thread_local_data.get_prompt_id()
try:
os.environ.setdefault('HEVOLVE_VLM_UNIFIED', 'true')
from integrations.vlm.vlm_adapter import (
execute_vlm_instruction, resolve_task_workspace)
# 180s is the floor for cold-start runs: first VLM forward pass alone
# can take 8-15s on the 4B model, and a realistic 12-iteration loop
# needs ~2.5 minutes. HEVOLVE_COMPUTER_ACTION_ETA lets the user raise
# it further without touching code.
max_eta = int(os.environ.get('HEVOLVE_COMPUTER_ACTION_ETA', '180'))
message = {
'instruction_to_vlm_agent': input_text,
'workspace_root': resolve_task_workspace(prompt_id=prompt_id),
'user_id': str(user_id or 'guest'),
'prompt_id': str(prompt_id or 0),
'os_to_control': 'windows' if sys.platform == 'win32' else (
'macos' if sys.platform == 'darwin' else 'linux'),
'max_ETA_in_seconds': max_eta,
}
result = execute_vlm_instruction(message)
if not (result and isinstance(result, dict)):
return "Computer action completed (no detailed response)."
exit_reason = result.get('exit_reason', 'done')
responses = result.get('extracted_responses', [])
elapsed = result.get('execution_time_seconds', 0)
n_actions = len(responses)
if exit_reason == 'done':
if responses:
last_content = responses[-1].get('content', '')
if isinstance(last_content, str):
return last_content[:500]
return f"Done — {n_actions} actions in {elapsed:.0f}s."
return f"Done in {elapsed:.0f}s."
if exit_reason == 'consent_required':
# The owner has not allowed agents to control this computer
# (integrations.vlm.safety.computer_control_block); the loop's one
# error record says so. Shell_Command needs the same permission,
# so no other route is offered.
return next((r.get('content') for r in responses
if r.get('type') == 'error' and r.get('content')),
'Not run: the owner has not allowed agents to control '
'this computer.')
# Non-done paths: be honest to the router so it doesn't confabulate.
# The router sees this string as the tool's final answer and should
# turn it into a truthful user reply instead of pretending success.
tail_action = None
for resp in reversed(responses):
if resp.get('type') == 'action':
tail_action = resp.get('content', {})
break
reason_msg = {
'timeout': f"I tried for {elapsed:.0f}s ({n_actions} actions) but ran out of time before finishing.",
'max_iterations': f"I tried {n_actions} actions but the task didn't reach a clear completion.",
'action_error': f"I hit errors on 3 consecutive actions after trying {n_actions} steps and stopped to avoid breaking things.",
'grounding_failed': f"I couldn't reliably locate the UI element after {n_actions} attempts.",
}.get(exit_reason, f"Loop exited with reason '{exit_reason}' after {n_actions} actions.")
if tail_action:
reason_msg += f" Last attempt: {tail_action.get('action', '?')} ({tail_action.get('reasoning', '')[:120]})."
reason_msg += " Want me to try a different approach, or should I describe the steps so you can do it manually?"
return reason_msg
except ImportError:
return "Computer use unavailable — VLM adapter not installed."
except Exception as e:
return f"Computer use error: {str(e)[:200]}"
def _handle_list_pending_actions_tool(input_text: str) -> str:
"""List the user's pending scheduled messages / reminders / jobs.
Answers questions like "what's next for me?", "what reminders do I
have today?", "show my upcoming tasks". Pulls from
ScheduledMessageManager (now durable via JSON persistence) and
apscheduler jobs if the scheduler service is running. The LLM gets
a plain-text summary with ISO timestamps so it can re-format for
the user in whatever style fits.
Input: optional filter hint like 'today', 'this week', 'tomorrow',
or '' for all pending. The filter is lexical — the tool lists
every pending message and lets the LLM narrow if it wants.
"""
try:
hint = (input_text or '').strip().lower()
lines: list[str] = []
# ── ScheduledMessageManager (durable JSON) ───────────────────
try:
from integrations.channels.automation.scheduled_messages import (
ScheduledMessageManager,
)
mgr = ScheduledMessageManager()
pending = mgr.list_pending()
if pending:
lines.append(f"Scheduled messages ({len(pending)} pending):")
for m in pending[:20]:
when = m.scheduled_time.isoformat() if m.scheduled_time else '?'
chan = m.channel_id or 'default'
snip = (m.content or '')[:120]
lines.append(f" • {when} [{chan}] {snip}")
if len(pending) > 20:
lines.append(f" … and {len(pending) - 20} more")
else:
lines.append("No scheduled messages pending.")
except ImportError:
logging.getLogger(__name__).debug("_handle_list_pending_actions_tool: swallowed ImportError")
except Exception as e:
lines.append(f"(scheduled messages unavailable: {str(e)[:80]})")
# ── APScheduler jobs (if active) ─────────────────────────────
try:
from integrations.channels.scheduler import get_scheduler
sched = get_scheduler()
if sched is not None:
jobs = list(sched.get_jobs())
if jobs:
lines.append(f"\nBackground jobs ({len(jobs)} active):")
for j in jobs[:20]:
next_run = getattr(j, 'next_run_time', None)
when = next_run.isoformat() if next_run else 'no next run'
lines.append(f" • {when} {j.id} {j.name or ''}")
except ImportError:
logging.getLogger(__name__).debug("_handle_list_pending_actions_tool: swallowed ImportError")
except Exception as e:
lines.append(f"(scheduler jobs unavailable: {str(e)[:80]})")
if not lines:
return "Nothing scheduled. Your action queue is empty."
if hint:
lines.insert(0, f"Filter hint: {hint}")
return "\n".join(lines)
except Exception as e:
return f"List_Pending_Actions error: {str(e)[:200]}"
def _wire_qr_pair_emitter(channel_type: str, meta: dict) -> None:
"""Hook the live adapter's existing `set_qr_callback` to the
Liquid UI `agent_ui_update` pipe so the user sees a QR right
where they typed "connect " — no admin-page navigation.
Single helper for every auth_method='qr_session' channel
(whatsapp, telegram_user, discord_user) — the per-channel adapter
already exposes the callback hook, this just bridges it to the
Liquid UI emitter that the form-based flows already use.
Best-effort: if the adapter isn't registered yet (e.g. fresh
bind before bootstrap), the QR will surface on next adapter
spawn via the same callback.
"""
try:
from core.platform.registry import get_registry as _platform_registry
from integrations.channels.registry import get_registry
_lui = _platform_registry().get_or_none('LiquidUIService')
if _lui is None:
return
adapter = get_registry().get(channel_type)
if adapter is None or not hasattr(adapter, 'set_qr_callback'):
return
# The card is FOR this user; None lets agent_ui_update fall back to
# the resolved owner rather than route to a user named 'system'.
_owner = thread_local_data.get_user_id() or None
user_id = _owner or 'system'
display_name = meta.get('display_name') or channel_type
def _emit_qr(qr: str) -> None:
try:
_lui.agent_ui_update(user_id, {
'type': 'qr_pair',
'channel': channel_type,
'title': f"Scan to connect {display_name}",
'help': (
f"Open {display_name} on your phone → Linked "
"devices → Link a device → scan this code."
),
'qr': qr,
}, user_id=_owner)
except Exception as e:
logger.debug("qr_pair emit failed: %s", e)
adapter.set_qr_callback(_emit_qr)
# Trigger an immediate (re)connect so the adapter spins up its
# WAHA / MTProto session and pushes the first QR via the
# callback. Reuses the same loop pattern the admin
# /reconnect endpoint uses — single source of truth.
try:
import asyncio as _asyncio
loop = _asyncio.new_event_loop()
try:
try:
loop.run_until_complete(adapter.disconnect())
except Exception:
logging.getLogger(__name__).exception("_wire_qr_pair_emitter: swallowed Exception")
loop.run_until_complete(adapter.connect())
finally:
loop.close()
except Exception as e:
logger.debug(
"qr_pair reconnect deferred (adapter spawns on bootstrap): %s",
e,
)
except Exception as e:
logger.debug("Connect_Channel: _wire_qr_pair_emitter failed: %s", e)
# A phone or tablet: its browser (Android / iPhone / iPad / iPod / Mobile) or
# a React Native app's HTTP client (okhttp on Android, CFNetwork on iOS).
# A desktop browser, Nunba's desktop webview and a server-side python client
# match none of these.
_PHONE_USER_AGENT = re.compile(
r'\b(?:Android|iPhone|iPad|iPod|Mobile|okhttp|CFNetwork)\b', re.IGNORECASE)
def _user_agent_is_phone(user_agent) -> bool:
"""Does this User-Agent belong to a phone or tablet? One that is the
device a WhatsApp QR would have to be scanned WITH, so showing it a QR
leaves nothing to scan it."""
return bool(_PHONE_USER_AGENT.search(user_agent or ''))
def _start_gateway_qr_pair_push(channel_type: str, meta: dict,
phone: str = None, owner=None):
"""Link a gateway_qr channel (WhatsApp) from chat, by the method that
works on the device the user is holding.
* A number is known -- given (the phone form, or 'whatsapp +91...'),
HEVOLVE_WHATSAPP_PHONE (operator choice) or the user's profile --
the pair code: chat card, consent.* notification that puts the code
on the phone's clipboard with an "Open WhatsApp" banner, and the iOS
fleet command. One tap on the phone, on any device.
* No number and the request came from a phone or tablet
(_user_agent_is_phone): the form asking for the number. A QR shown
on the phone cannot be scanned by that same phone.
* No number, on a desktop: WhatsApp's linked-device QR (``qr_pair``
cards), refreshed as WhatsApp rotates it (~20s) until it is scanned,
with "Link with phone number" on the card as the alternative.
The gateway serves the two methods on different sockets
(request-pair-code tears the QR socket down), so one attempt uses one.
One watcher polls /api/sessions//status; when WhatsApp confirms, it
registers the binding, wires the live adapter and sends the
channel_connected card. Every failure (gateway down, no code, the
window running out, a card or push not delivered) is a warning in the
log, and the ones the user must act on are a toast in chat.
Args:
phone: explicit number for "Link with phone number"; any format,
digits are kept.
owner: the user the cards are for, when the caller is a request
handler without a thread-local user (the re-entry route).
Returns 'pair_code', 'qr' or 'ask_phone': what the user was shown, for
the reply to describe. None when the link could not start (the user
was told why in chat).
"""
import os
import json as _json
import logging as _logging
import threading
import time
# The ONE gateway client (api_channels): same base URL resolution as
# the /whatsapp/qr route and the X-Api-Key a remote WAHA needs. Never
# raises; an unreachable gateway is (None, 503).
from integrations.social.api_channels import (
_proxy_gateway, _whatsapp_gateway_base,
)
# Self-contained logger so the helper works whether the module's
# global `logger` is initialised yet or not (helps in standalone
# probes that load this helper before full HARTOS bootstrap).
_log = _logging.getLogger(__name__)
# _owner is who the cards are FOR (None -> agent_ui_update's resolved
# owner); user_id keeps its 'system' placeholder for the gateway session.
_owner = owner or thread_local_data.get_user_id() or None
user_id = _owner or 'system'
sid = user_id if str(user_id).startswith('user_') else f"user_{user_id}"
display_name = meta.get('display_name') or channel_type
icon = meta.get('icon') or channel_type
color = meta.get('color') or '#25D366'
deeplink = (
'whatsapp://settings/linked-devices'
if channel_type == 'whatsapp'
else meta.get('mobile_deeplink')
)
def _emit(payload: dict) -> None:
"""One card to this user's chat; a missing service is logged."""
try:
from core.platform.registry import get_registry
_lui = get_registry().get_or_none('LiquidUIService')
if _lui is None:
_log.warning("gateway_qr: %s card not delivered, no "
"LiquidUIService", payload.get('type'))
return
_lui.agent_ui_update(user_id, payload, user_id=_owner)
except Exception as e:
_log.warning("gateway_qr: %s card not delivered: %s",
payload.get('type'), e)
def _fail(text: str) -> None:
_log.warning("gateway_qr: %s: %s", channel_type, text)
_emit({'type': 'toast', 'severity': 'error',
'channel': channel_type, 'channel_type': channel_type,
'message': text})
# The number, in order: given, the operator's, the user's profile.
# WhatsApp's pair-code endpoint REQUIRES E.164 digits-only.
phone = phone or os.environ.get('HEVOLVE_WHATSAPP_PHONE', '') or ''
if not phone and _owner:
try:
from integrations.social.models import db_session, User as _SocialUser
with db_session(commit=False) as db:
u = db.query(_SocialUser).filter_by(id=str(_owner)).first()
phone = (getattr(u, 'phone_number', '') or getattr(u, 'phone', '') or '') if u else ''
except Exception as e:
_log.warning("gateway_qr: profile phone not read for %s (%s); "
"linking without it", _owner, e)
phone = ''.join(ch for ch in str(phone) if ch.isdigit())
if not phone and _user_agent_is_phone(thread_local_data.get_client_user_agent()):
# On the phone itself: ask for the number, then send the pair code.
# The form posts to the re-entry route, which calls back here with
# the number.
_emit({
'type': 'form',
'title': f"Connect {display_name}",
'channel': channel_type,
'fields': [{
'name': 'phone',
'label': f'Your {display_name} number, with country code',
'placeholder': '+91 ...',
'help': ("Used once to get a linking code for this phone. "
"Not stored."),
'type': 'tel',
'secret': False,
}],
'submit_label': 'Send me a code',
'action': f'/api/social/channels/{channel_type}/connect-pair-code',
})
return 'ask_phone'
base = _whatsapp_gateway_base()
started, start_status = _proxy_gateway('POST', f'/api/sessions/{sid}/start')
if started is None or start_status >= 400:
_fail(f"Couldn't reach the {display_name} link service on this "
f"computer (HTTP {start_status}"
f"{': ' + str(started.get('error')) if started and started.get('error') else ''}"
f"). It may still be starting; try again in a minute.")
return None
# One live attempt per session: a newer connect (say, the phone form
# after the QR) supersedes this watcher, so two never both announce.
# An attempt removes itself when it ends (_retire), so the map holds
# only live attempts.
_attempts = _start_gateway_qr_pair_push.__dict__.setdefault('attempts', {})
attempt = object()
_attempts[sid] = attempt
def _retire() -> None:
if _attempts.get(sid) is attempt:
_attempts.pop(sid, None)
if phone:
# Baileys needs ~3s for the WA noise handshake before
# requestPairingCode succeeds — manual probe earlier today (the
# FA9K4NHK code) verified this timing.
time.sleep(3)
body, _st = _proxy_gateway(
'POST', f'/api/sessions/{sid}/request-pair-code',
json={'phone': phone})
body = body or {}
code = body.get('code') if _st < 400 else None
if not code:
_retire()
_fail(f"{display_name} didn't return a pairing code "
f"({body.get('error') or 'no code'}). Say 'connect "
f"{channel_type}' without a number to scan a QR instead.")
return None
push_payload = {
'kind': 'channel_pair_code',
'channel': channel_type,
'display_name': display_name,
'color': color,
'icon': icon,
'code': code,
'clipboard_payload': code,
'deeplink': deeplink,
'expires_in': 60,
}
# Mobile push via existing NotificationService. Type uses the
# canonical 'consent.*' prefix so the existing NotificationBell
# resolveTargetPath route (consent.* → /admin/consent/{ref}) and
# AutobahnConnectionManager 'consent.*' Intent dispatch (P0-D)
# catch it without per-type bell code. target_id carries the
# channel name so /admin/consent/{whatsapp} lands on the right
# channel admin context. Message JSON keeps the deeplink so the
# generic P0-C deeplink override in resolveTargetPath wins on
# click — that takes the user to the channel pair page directly.
#
# Order matters: NotificationService.create runs BEFORE the
# LiquidUI emit so we can pass notif.id into the chat-card
# payload. PairCodeOverlay (P1-S3) calls notificationsApi.markRead
# with that id on countdown expiry so the bell doesn't carry an
# orphan unread row for an expired pair code.
notif_id = None
try:
from integrations.social.services import NotificationService
from integrations.social.models import db_session
with db_session() as db:
notif = NotificationService.create(
db,
user_id=str(user_id),
type='consent.channel_pair_code',
target_id=channel_type,
target_type='channel',
message=_json.dumps(push_payload),
)
notif_id = getattr(notif, 'id', None)
except Exception as e:
_log.warning("gateway_qr: pair code not pushed to %s's phone "
"(it is still in chat): %s", user_id, e)
# In-chat card via the existing LiquidUI emit pipe.
try:
from core.platform.registry import get_registry
_lui = get_registry().get('LiquidUIService')
if _lui:
_lui.agent_ui_update(
user_id,
{
'type': 'pair_code',
'channel': channel_type,
'channel_type': channel_type,
'display_name': display_name,
'color': color,
'icon': icon,
'code': code,
'expires_in': 60,
'clipboard_payload': code,
'deeplink': deeplink,
'notification_id': notif_id,
'instructions': (
f"Open {display_name} on your phone → "
f"Settings → Linked Devices → Link a Device → "
f"Link with phone number → enter {code} "
f"(60-second window). I've also pushed it "
f"to your phone with auto-copy to clipboard."
),
}, user_id=_owner,
)
except Exception as e:
_log.warning("gateway_qr: pair-code card not delivered: %s", e)
# ── 2026-05-26 P0-E ──────────────────────────────────────────────
# Fleet fanout for iOS native (Nunba-Companion-iOS) which
# subscribes to com.hertzai.hevolve.fleet.user.{user_id} and routes
# the existing 'agent_consent' cmd_type via FleetCommandReceiver
# allowlist — no iOS code change required. Per
# core/peer_link/ui_commands.py module docstring: consent flows
# MUST use cmd_type='agent_consent' (NOT ui_overlay_show); the
# helper is "future work" per that docstring, so we publish via
# message_bus directly with the documented shape. Web/desktop
# already get this card via the agent_ui_update + Notification
# emits above; Android gets it via the consent.* WAMP dispatcher
# (P0-D); this closes the iOS gap with one canonical route.
try:
from core.peer_link.message_bus import get_message_bus
get_message_bus().publish(
'fleet.command.user',
{
'cmd_type': 'agent_consent',
'id': f"consent-{channel_type}-{int(time.time())}",
'title': f"Connect {display_name}",
'body': (
f"Code {code} auto-copied to clipboard. Open "
f"{display_name} → Linked Devices and enter the "
f"code within 60s."
),
'code': code,
'clipboard_payload': code,
'deeplink': deeplink,
'expires_in_seconds': 60,
'channel': channel_type,
'display_name': display_name,
},
user_id=str(user_id),
)
except Exception as e:
_log.warning("gateway_qr: pair code not sent to the iOS "
"companion: %s", e)
def _watch():
"""Show each fresh QR; on pairing, register, go live and confirm.
Window covers 4 pair-code retries (each WA expires after ~60s) and
several QR rotations. Daemon, so shutdown never waits on it.
"""
deadline = time.time() + 240
last_qr = None
shown = 0
while time.time() < deadline:
if _attempts.get(sid) is not attempt:
_log.info("gateway_qr: %s watcher for %s superseded by a "
"newer connect", channel_type, sid)
return
body, _st = _proxy_gateway('GET', f'/api/sessions/{sid}/status')
if body is None or _st >= 400:
_log.debug("gateway_qr: status poll HTTP %s", _st)
body = {}
if body.get('authenticated'):
# Register binding so Hevolve/Nunba see channel as
# connected. Re-uses the SAME register_channel
# closure Connect_Channel calls — single code path.
try:
from integrations.channels.agent_tools import (
build_channel_tool_closures,
)
_tools = build_channel_tool_closures(
{'user_id': str(user_id),
'prompt_id': thread_local_data.get_prompt_id()},
) or []
_reg = next(
(t[2] for t in _tools
if isinstance(t, tuple) and len(t) >= 3
and t[0] == 'register_channel'),
None,
)
if _reg is not None:
reg_out = _reg(channel_type, '{}')
_log.info(
"gateway_qr: register_channel after "
"pair: %s", str(reg_out)[:200])
except Exception as reg_err:
_log.warning(
"gateway_qr: register_channel after pair "
"failed: %s", reg_err)
# Live adapter: register_channel above only writes the
# UserChannelBinding row (so the UI shows "connected")
# — it never wires a real WhatsAppAdapter into the
# running ChannelRegistry, so inbound messages never
# reached the agent. This is the actual transport.
if channel_type == 'whatsapp':
_adapter_out = _ensure_whatsapp_live_adapter(
user_id, sid=sid, base=base)
_log.info(
"gateway_qr: live adapter registration: %s",
_adapter_out)
# Chat success card.
_emit({
'type': 'channel_connected',
'channel': channel_type,
'channel_type': channel_type,
'display_name': display_name,
'color': color,
'icon': icon,
'message': f"✅ {display_name} connected.",
})
_retire()
return
qr = body.get('qr')
if qr and qr != last_qr and not phone:
last_qr = qr
shown += 1
_emit({
'type': 'qr_pair',
'channel': channel_type,
'channel_type': channel_type,
'title': f"Scan to connect {display_name}",
'help': (f"Open {display_name} on your phone → Settings "
f"→ Linked devices → Link a device, and scan "
f"this code."),
'qr': qr,
# One id per code; the overlay replaces the channel's
# card in place with each newer code.
'msg_id': f"qr_pair-{channel_type}-{sid}-{shown}",
# The alternative for someone whose only device is the
# phone: "Link with phone number" posts {phone} here.
'pair_code_action': (
f'/api/social/channels/{channel_type}/connect-pair-code'),
})
time.sleep(2)
if _attempts.get(sid) is not attempt:
return
_retire()
_fail(f"The {display_name} link expired before it was completed. "
f"Say 'connect {channel_type}' to get a new "
f"{'code' if phone else 'QR code'}.")
threading.Thread(
target=_watch, daemon=True,
name=f'connect_channel_poll_{channel_type}',
).start()
return 'pair_code' if phone else 'qr'
_whatsapp_adapter_lock = threading.Lock()
def _fetch_whatsapp_own_identity(gw_base: str, sid: str) -> dict:
"""GET the gateway's own-identity for ``sid``.
Returns ``{'own_jid': ..., 'own_lid': ...}`` (either may be None while
the gateway is still linking), or ``{}`` on any failure, logged. ONE
bounded request; the caller decides when to try again.
"""
import json as _json
import urllib.request as _urlreq
log = logging.getLogger(__name__)
try:
with _urlreq.urlopen(
f"{gw_base.rstrip('/')}/api/sessions/{sid}/status",
timeout=5.0,
) as _resp:
_status = _json.loads(_resp.read().decode('utf-8', errors='replace'))
return {'own_jid': _status.get('own_jid'), 'own_lid': _status.get('own_lid')}
except Exception as _status_err:
log.warning(
"_ensure_whatsapp_live_adapter: could not fetch own identity "
"from gateway (self-chat detection is off until a later poll "
"fetches it): %s", _status_err)
return {}
def _ensure_whatsapp_live_adapter(
user_id, sid: str = None, base: str = None,
) -> dict:
"""Idempotently construct + register a live WhatsAppAdapter for an
already-authenticated gateway_qr session, wiring it into the
process-global ChannelRegistry so inbound WhatsApp messages reach
the agent (via FlaskChannelIntegration._handle_message) and
replies actually get sent back.
register_channel (agent_tools.py) only ever wrote the
UserChannelBinding DB row — it never did this. Without a live
adapter subscribed to the gateway's WebSocket, "connected" in the
DB/UI was cosmetic: nothing was listening for real messages.
Known limitation (unchanged by this fix, not attempted here):
ChannelRegistry keys adapters by channel TYPE, not per user, so
only one live WhatsApp session can be registered process-wide at
a time. Fine for a single-user desktop/local deployment; a real
multi-tenant cloud deployment needs a per-user registry — separate,
larger piece of work.
"""
import asyncio as _aio
import os as _os
sid = sid or (
str(user_id) if str(user_id).startswith('user_')
else f"user_{user_id}"
)
log = logging.getLogger(__name__)
try:
from integrations.channels.flask_integration import (
get_channel_integration,
)
integration = get_channel_integration()
# This function is called on every /whatsapp/qr poll (the mobile
# app's QRScannerScreen polls every ~3s) plus the chat-based
# Connect_Channel path — under concurrent requests, the
# check-then-register below was NOT atomic: two threads could
# both see "not registered yet" before either finished
# registering, each construct + register their OWN adapter with
# its OWN WebSocket connection to the gateway. The gateway then
# broadcasts every inbound message to ALL connected sockets, so
# N racing registrations meant N independent self_chat.handle()
# calls (and N separate /chat + LLM calls) for the SAME message —
# confirmed live 2026-07-28 as repeated near-duplicate replies
# sent to a real WhatsApp self-chat a few seconds apart. The lock
# makes the whole check+register sequence atomic so only ONE
# adapter (and one WebSocket connection) is ever created per
# process, no matter how many requests race here.
gw_base = base or _os.environ.get(
'WHATSAPP_GATEWAY_URL',
f"http://127.0.0.1:"
f"{_os.environ.get('WHATSAPP_GATEWAY_PORT', '3000')}",
)
with _whatsapp_adapter_lock:
existing = integration.registry.get('whatsapp')
if existing is not None:
# A gateway that was still linking when the adapter was
# registered gave no identity. The adapter stays (messages
# must flow), and because every later poll lands here, this
# is the only place the identity can still be adopted: one
# bounded fetch per poll until the adapter carries one.
if not existing.has_owner_identity():
identity = _fetch_whatsapp_own_identity(gw_base, sid)
if identity.get('own_jid') or identity.get('own_lid'):
existing.set_owner_identity(
identity.get('own_jid'), identity.get('own_lid'))
log.info(
"whatsapp adapter adopted its owner identity on a "
"later poll (account_id=%s)", sid)
return {
'success': True,
'message': f'whatsapp adapter already registered '
f'(account_id={getattr(existing, "_account_id", "?")})',
}
from integrations.channels.whatsapp_adapter import (
create_whatsapp_adapter,
)
# Fetch the gateway's own-identity lookup so self-chat detection
# (SelfChatHandler.is_self_message) has something to match against
# — without this, owner_phone/owner_lid are never set and a
# self-chat message can never be recognized, regardless of which
# JID scheme WhatsApp uses for that account (own_jid for
# phone-based accounts, own_lid for LID/privacy-ID accounts).
identity = _fetch_whatsapp_own_identity(gw_base, sid)
adapter = create_whatsapp_adapter(
api_url=gw_base, account_id=sid,
phone_number=identity.get('own_jid'),
owner_lid=identity.get('own_lid'),
)
integration.registry.register(adapter)
# Entry points that skip hartos_bootstrap never call start(), so
# the loop may not exist yet; ensure_running() starts it on demand.
loop, started_integration = integration.ensure_running()
if loop is None:
return {
'success': False,
'error': 'channel event loop failed to start '
'(FlaskChannelIntegration.start() ran but '
'_loop never became live)',
}
# A newly started integration invokes registry.start_all(), which
# starts this adapter exactly once. An already-running loop needs
# this explicit start because registry.start_all() has completed.
if not started_integration:
_aio.run_coroutine_threadsafe(adapter.start(), loop)
log.info(
"whatsapp live adapter registration scheduled "
"(account_id=%s, base=%s)", sid, gw_base,
)
return {
'success': True,
'message': f'whatsapp adapter registration scheduled for {sid}',
}
except Exception as e:
log.warning("_ensure_whatsapp_live_adapter failed: %r", e)
return {'success': False, 'error': repr(e)[:300]}
def _backfill_channel_binding_post_pair(
channel_type: str, user_id: str,
) -> dict:
"""One-shot reconciliation: gateway says authenticated:true but
HARTOS has no UserChannelBinding for this channel/user, OR the
binding exists but no live adapter is wired in (e.g. a pairing
done before _ensure_whatsapp_live_adapter existed, or an
out-of-band pair via manual curl / a prior session where the
polling thread never ran). Calls register_channel('whatsapp', '{}')
to close the binding gap, then wires the live adapter. Idempotent.
"""
try:
from integrations.channels.agent_tools import (
build_channel_tool_closures,
)
tools = build_channel_tool_closures(
{'user_id': str(user_id), 'prompt_id': None},
) or []
reg = next(
(t[2] for t in tools
if isinstance(t, tuple) and len(t) >= 3
and t[0] == 'register_channel'),
None,
)
if reg is None:
return {'success': False, 'error': 'register_channel unavailable'}
out = reg(channel_type, '{}')
adapter_out = None
if channel_type == 'whatsapp':
adapter_out = _ensure_whatsapp_live_adapter(user_id)
return {
'success': True,
'message': str(out)[:400],
'adapter': adapter_out,
}
except Exception as e:
return {'success': False, 'error': repr(e)[:300]}
def _handle_connect_channel_tool(input_text: str) -> str:
"""Register / connect a messaging channel (WhatsApp, Telegram, Discord,
Slack, etc.) from natural-language chat. Delegates to the existing
`register_channel` closure in integrations.channels.agent_tools so there's
exactly ONE registration code path — this is just the LangChain facade.
Input formats accepted:
- "whatsapp" → start setup, returns missing-fields
- "telegram {\"bot_token\":...}" → register with config
- '{"channel": "slack",
"config": {"bot_token":...}}' → full JSON dict
"""
import json as _json
try:
channel_type = None
config_json = '{}'
text = (input_text or '').strip()
if text.startswith('{'):
try:
parsed = _json.loads(text)
channel_type = (parsed.get('channel') or parsed.get('channel_type') or '').lower()
cfg = parsed.get('config') or {}
config_json = _json.dumps(cfg) if isinstance(cfg, dict) else str(cfg)
except _json.JSONDecodeError:
logging.getLogger(__name__).debug("_handle_connect_channel_tool: swallowed _json.JSONDecodeError", exc_info=True)
if not channel_type:
parts = text.split(None, 1)
channel_type = parts[0].lower() if parts else ''
if len(parts) > 1:
config_json = parts[1].strip() or '{}'
if not channel_type:
return (
"Which channel should I connect? Say 'whatsapp', 'telegram', 'slack', "
"'discord', etc. You can also include credentials like "
"'telegram {\"bot_token\": \"123:ABC\"}'."
)
from integrations.channels.agent_tools import build_channel_tool_closures
ctx = {
'user_id': thread_local_data.get_user_id(),
'prompt_id': thread_local_data.get_prompt_id(),
}
tools = build_channel_tool_closures(ctx) or []
register_fn = next(
(t[2] for t in tools
if isinstance(t, tuple) and len(t) >= 3 and t[0] == 'register_channel'),
None,
)
if register_fn is None:
return "Channel registration is not available on this node."
result = register_fn(channel_type, config_json)
# Liquid UI co-pilot: when the tool reports missing-credential
# fields, surface a floating form via agent_ui_update so the
# existing AgentOverlay FormOverlay (case 'form' renderer)
# collects the credentials inline in chat instead of forcing
# the user to navigate to the admin Channels page. The text
# return is preserved unchanged so the LangChain agent can
# summarize it to the user — the form is the IMPERATIVE next
# step, the text is context. Best-effort: never raise, never
# block the tool return. Same emit pattern model_orchestrator
# already uses (see notify_loaded line ~872).
try:
if isinstance(result, str) and 'Missing:' in result:
from integrations.channels.metadata import (
get_channel_metadata, is_oauth_configured,
)
meta = get_channel_metadata(channel_type) or {}
# ── OAuth click-through fork (PR O) ────────────────
# When the operator has set HARTOS_OAUTH_CLIENT_
# env, prefer click-through over paste-form. Calls the
# SAME ``build_authorize_url`` helper /api/oauth/start
# uses (oauth_api.build_authorize_url) — single source
# of URL truth. Emits a Liquid UI ``oauth_link``
# payload that AgentOverlay's case 'oauth_link' renders.
# Falls through to the paste-form on any failure.
if is_oauth_configured(channel_type):
try:
from integrations.channels.oauth_api import (
build_authorize_url,
)
authorize_url, _state = build_authorize_url(
user_id=int(thread_local_data.get_user_id() or 0),
channel_type=channel_type,
)
from core.platform.registry import get_registry
_lui = get_registry().get_or_none('LiquidUIService')
if _lui:
_lui.agent_ui_update(
thread_local_data.get_user_id() or 'system',
{
'type': 'oauth_link',
'channel': channel_type,
'channel_type': channel_type,
'display_name': meta.get('display_name') or channel_type,
'color': meta.get('color') or '#6c63ff',
'icon': meta.get('icon') or 'link',
'url': authorize_url,
# COMPONENT_TYPES['oauth_link'] declares
# authorize_url/provider/title
# (liquid_ui_service.py:690) and the web
# renderer builds its ONLY action from
# `data.authorize_url`
# (AgentOverlay.jsx:736). Only `url` was
# emitted, so actions was [] and the card
# rendered "Sign in to service" with
# nothing to click -- the handshake could
# not be completed on the desktop. `url`
# stays: the RN card reads it
# (AgentInlineChatCard.js:382).
'authorize_url': authorize_url,
'provider': meta.get('display_name') or channel_type,
'title': f"Sign in to {meta.get('display_name') or channel_type}",
'external_url': meta.get('external_url'),
'cta_label': f"Connect with {meta.get('display_name') or channel_type}",
},
user_id=thread_local_data.get_user_id() or None,
)
return (
f"To connect {meta.get('display_name') or channel_type}, "
f"click the button I just sent — it will open the provider's "
f"sign-in page in your browser. After you authorize, the "
f"channel will be connected automatically."
)
except Exception as oauth_err:
logger.debug(
"Connect_Channel: OAuth fork failed, falling back to form: %s",
oauth_err,
)
# Fall through to the paste-form path below.
setup_fields = meta.get('setup_fields') or []
# Skip auto:True fields (WAHA api_url, access_token, etc)
# — these are server-provisioned via env-var defaults in
# register_channel; surfacing them in the chat form would
# ask the user about gateway infrastructure they shouldn't
# need to know about. Operator override stays available
# via the admin Channels page (which shows ALL fields).
visible_fields = [f for f in setup_fields if not f.get('auto')]
if visible_fields:
from core.platform.registry import get_registry
from security.edge_privacy import (
is_secret_key as _is_secret_key,
secret_key_form as _secret_key_form)
_lui = get_registry().get_or_none('LiquidUIService')
if _lui:
_lui.agent_ui_update(
thread_local_data.get_user_id() or 'system',
{
'type': 'form',
'title': f"Connect {meta.get('display_name') or channel_type}",
'channel': channel_type,
'fields': [
{
'name': f.get('key'),
'label': f.get('label') or f.get('key'),
'placeholder': f.get('placeholder') or '',
# The one secret-name rule, with any
# '*_key' masked too: a channel's
# signing or app key is typed into
# this box ('password' was not).
'secret': bool(
f.get('secret')
or _is_secret_key(
_secret_key_form(f.get('key') or ''),
('key',))
),
'help': f.get('help') or '',
'type': f.get('type') or 'text',
'default': f.get('default'),
}
for f in visible_fields
],
# external_url surfaces a "Open "
# button at the top of the form so users
# can launch BotFather / Discord dev portal
# / Slack app installer in their browser
# before pasting the resulting token.
'external_url': meta.get('external_url'),
'submit_label': 'Connect',
# The renderer POSTs the field values to
# `action` (Nunba FormOverlay). The old
# submit_action name was read by nothing,
# so Connect dropped the typed token.
'action': f'/api/social/channels/{channel_type}/connect',
},
user_id=thread_local_data.get_user_id() or None,
)
except Exception as e:
logger.debug("Connect_Channel: liquid UI emit skipped: %s", e)
# ── QR-session post-success hookup ────────────────────────
# For channels with auth_method='qr_session' (whatsapp,
# telegram_user, discord_user) the binding alone isn't enough —
# the user also needs to scan a QR with their existing client
# to authorize the session. Reuse the existing
# `set_qr_callback` on each adapter and the existing
# `agent_ui_update` Liquid UI emit pipe (same emitter the
# form uses, just a new payload kind). Single hook for all
# three channels — DRY.
try:
if isinstance(result, str) and 'registered and enabled' in result:
from integrations.channels.metadata import get_channel_metadata
meta = get_channel_metadata(channel_type) or {}
if meta.get('auth_method') == 'qr_session':
_wire_qr_pair_emitter(channel_type, meta)
except Exception as e:
logger.debug("Connect_Channel: qr_pair wire skipped: %s", e)
# ── gateway_qr conversational pair-code push ───────────────
# NEW (2026-05-25): for channels with auth_method='gateway_qr'
# (WhatsApp via the embedded Baileys gateway) the canonical
# onboarding is an 8-char pair-code OTP rather than scanning
# a QR. Rather than dumping the code into the React UI and
# making the user retype, we:
# 1. mint the code at the local gateway,
# 2. emit an agent_ui_update kind='pair_code' so the chat
# panel renders a copy-button card,
# 3. persist a Notification of type='consent.channel_pair_code'
# with the payload as JSON in the message field (the
# consent.* prefix is what every surface's existing route
# catches — bell, Android overlay dispatcher, iOS fleet
# handler). RN
# ConsentOverlayService consumes that, does
# Clipboard.setString(code), and shows an "Open WhatsApp"
# banner whose tap navigates to the deeplink with the
# code already on the clipboard for paste,
# 4. spawn a daemon thread polling the gateway session;
# on authenticated:true emit kind='channel_connected' so
# chat shows the success card automatically.
# The full chain (gateway require()→import() patch,
# baileys 7.0.0-rc13 upgrade, auto-reconnect on code 515
# restart-required) lands the user in state='connected' with
# zero technical detail surfaced to them.
try:
from integrations.channels.metadata import get_channel_metadata
meta = get_channel_metadata(channel_type) or {}
if meta.get('auth_method') == 'gateway_qr':
# A number in the input asks for a pair code. Accepts the
# JSON form ('whatsapp {"phone": "+91..."}') or a bare number
# ('whatsapp +91 90030 54371'); fewer than 8 digits is not a
# phone number. With none, the helper picks by the number on
# file and the device (_start_gateway_qr_pair_push).
try:
_cfg = _json.loads(config_json or '{}')
except ValueError:
_cfg = config_json
if isinstance(_cfg, dict):
_cfg = _cfg.get('phone') or _cfg.get('phone_number') or ''
_phone = ''.join(ch for ch in str(_cfg or '') if ch.isdigit())
if len(_phone) < 8:
_phone = ''
_how = _start_gateway_qr_pair_push(channel_type, meta,
phone=_phone or None)
_name = meta.get('display_name') or channel_type
result = f"{result} " + {
'pair_code': (
f"I sent a linking code to this chat and to your "
f"phone, where it is already copied: in {_name} open "
f"Linked devices > Link a device > Link with phone "
f"number and paste it."),
'ask_phone': (
f"Enter your {_name} number in the form I sent and "
f"I'll send you a linking code: a QR code can't be "
f"scanned by the phone showing it."),
'qr': (
f"Scan the QR code in this chat from {_name} on your "
f"phone (Linked devices > Link a device). If you "
f"can't scan, use 'Link with phone number' on the "
f"card."),
}.get(_how, f"But the {_name} link couldn't start; the "
f"message in chat says why.")
except Exception as e:
logger.warning("Connect_Channel: %s link not started: %s",
channel_type, e)
return result
except Exception as e:
return f"Channel connect error: {str(e)[:200]}"
def _handle_invite_friend_tool(input_text: str) -> str:
"""Generate a shareable Nunba invite link the user can send to a friend.
Mirrors the _handle_connect_channel_tool pattern (G1, plan
elegant-spinning-avalanche). Reuses the canonical referral primitive
``DistributionService.get_or_create_referral_code`` (idempotent —
same user → same code) and the canonical URL builder
``invite_share_url`` so the link format stays a single-writer.
Input formats:
- "" (empty) — generic "share with anyone" link
- "" — same link, just
echoed back in the friendly reply for the LLM to weave in
Returns text including the share URL so the LangChain agent can
summarize for the user. Also fires a Liquid UI ``form`` card via
``agent_ui_update`` (same emit pattern as Connect_Channel) so the
AgentOverlay renders an inline floating share-card with copy +
"share via channel" actions.
"""
try:
uid = thread_local_data.get_user_id()
if not uid:
return "Cannot generate invite — no signed-in user in this session."
from integrations.social.distribution_service import (
DistributionService, invite_share_url,
)
from integrations.social.models import get_db
db = get_db()
try:
ref = DistributionService.get_or_create_referral_code(db, str(uid))
db.commit()
finally:
db.close()
code = ref.get('code') or ''
if not code:
return "Failed to generate invite code — please try again."
url = invite_share_url(code)
# Liquid UI co-pilot: emit a share card so the user can copy /
# send the link with one click. Same pattern Connect_Channel
# uses (line 2891-2925), same canonical AgentOverlay 'form'
# renderer. Best-effort — never blocks the tool return.
try:
from core.platform.registry import get_registry
_lui = get_registry().get('LiquidUIService')
if _lui:
_lui.agent_ui_update(
str(uid),
{
'type': 'form',
'title': 'Share invite',
'channel': 'invite',
'fields': [
{
'name': 'invite_url',
'label': 'Your invite link',
'value': url,
'readonly': True,
},
],
'submit_label': 'Copy link',
'submit_action': 'copy_invite_url',
}, user_id=str(uid),
)
except Exception as e:
logger.debug("Invite_Friend: liquid UI emit skipped: %s", e)
context_hint = (input_text or '').strip()
suffix = f" (context: {context_hint})" if context_hint else ""
return (
f"Here is your shareable invite link: {url}{suffix}. "
f"Send it via any channel — anyone who joins via this link "
f"is credited as your referral."
)
except Exception as e:
return f"Invite_Friend error: {str(e)[:200]}"
def _handle_join_external_room_tool(input_text: str) -> str:
"""Join an external room (Discord channel, Slack channel, Telegram
super-group, Matrix room, Teams meet, WhatsApp group, etc.) on
behalf of the user, with consent + announcement guardrails.
Mirrors the _handle_connect_channel_tool pattern (G2, plan
elegant-spinning-avalanche). Reuses canonical primitives:
- room_presence_service.gate (consent check, fail-closed)
- room_presence_service.announce_presence (HIVE MISSION
announcement on join)
- room_presence_service.listen_for_objection (auto-leave
on participant 'no AI' reply)
- ChannelRegistry.get / RoomCapableAdapter.join_room (transport)
Input formats:
- " " (default role=co_pilot)
- " "
- JSON: {"platform":..., "room":..., "role":...}
Roles ∈ {co_pilot, participant, note_taker, silent_observer, writer}.
Returns a human-readable string the LangChain agent can summarize.
Best-effort: never raises out, always returns a string.
"""
import json as _json
try:
uid = thread_local_data.get_user_id()
if not uid:
return "Cannot join external room — no signed-in user in this session."
platform = ''
room_id = ''
role = 'co_pilot'
text = (input_text or '').strip()
if text.startswith('{'):
try:
parsed = _json.loads(text)
platform = (parsed.get('platform') or parsed.get('channel') or '').lower()
room_id = str(parsed.get('room') or parsed.get('room_id') or '')
role = (parsed.get('role') or 'co_pilot').lower()
except _json.JSONDecodeError:
logging.getLogger(__name__).debug("_handle_join_external_room_tool: swallowed _json.JSONDecodeError", exc_info=True)
if not platform or not room_id:
parts = text.split(None, 2)
if len(parts) >= 1:
platform = platform or parts[0].lower()
if len(parts) >= 2:
room_id = room_id or parts[1]
if len(parts) >= 3 and not role:
role = parts[2].lower()
if not platform:
return (
"Which platform should I join? Tell me the platform "
"(discord, slack, telegram, matrix, teams, whatsapp) "
"and the room id or URL."
)
if not room_id:
return f"Which {platform} room should I join? Send the room id or URL."
# 1. Consent gate (canonical path)
from integrations.social.room_presence_service import (
announce_presence, gate, listen_for_objection,
)
allowed, reason = gate(str(uid), platform, room_id, role)
if not allowed:
# Surface a Liquid UI consent card so the user can grant in one click.
try:
from core.platform.registry import get_registry
_lui = get_registry().get_or_none('LiquidUIService')
if _lui:
# A NOTIFICATION, not an approval card: the gate already
# decided, and this only points the owner at where to
# grant. It was typed 'approval', which renders
# AgentOverlay's ApprovalOverlay -- a card that reads
# `description` (absent here, so a blank body), never
# renders `actions` at all, and offers Approve/Deny
# buttons that POST agent_id=undefined, action=undefined
# to /api/agent/approval. 'notification' is the type
# whose contract is exactly this payload's shape:
# title + message + severity + actions
# (liquid_ui_service COMPONENT_TYPES).
#
# And the action key is `kind`, not `action`: the
# renderer dispatches on kind=='navigate' and falls
# through to an "unhandled action" warning otherwise, so
# the button did nothing even once the type was right.
_lui.agent_ui_update(
str(uid),
{
'type': 'notification',
'title': 'Permission needed',
'message': reason,
'severity': 'info',
'actions': [
{'label': 'Open Privacy Settings',
'kind': 'navigate',
'target': '/social/settings/privacy'},
],
},
user_id=str(uid),
)
except Exception as e:
logger.debug("Join_External_Room: consent UI emit skipped: %s", e)
return reason
# 2. Resolve adapter + check room-capable
try:
from integrations.channels.registry import get_registry
from integrations.channels.room_capable import (
UnsupportedRoomError, is_room_capable,
)
registry = get_registry()
adapter = registry.get(platform)
except Exception as e:
return f"Could not look up {platform} adapter: {str(e)[:160]}"
if adapter is None:
return (
f"No {platform} channel is configured. Set it up first "
f"via Connect_Channel ('add channel {platform}') and try again."
)
if not is_room_capable(adapter):
return (
f"The {platform} adapter is configured but does not yet "
f"support room/channel join semantics. Platform support "
f"is pending — for now you can use Connect_Channel for "
f"1:1 messaging."
)
# 3. Join via the adapter (RoomCapableAdapter.join_room)
import asyncio
try:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
fut = asyncio.run_coroutine_threadsafe(
adapter.join_room(room_id, role), loop)
joined = fut.result(timeout=30)
else:
joined = loop.run_until_complete(
adapter.join_room(room_id, role))
except RuntimeError:
joined = asyncio.run(adapter.join_room(room_id, role))
except UnsupportedRoomError as e:
return f"{platform} doesn't support rooms here: {e}"
except Exception as e:
return f"Failed to join {platform}/{room_id}: {str(e)[:160]}"
if not joined:
return (
f"Could not join {platform}/{room_id} — the adapter "
f"refused the join (rate limit, missing permission, or "
f"network). Try again in a moment."
)
# 4. Announce presence (HIVE MISSION — non-optional)
# Resolve the owner's display name from the user record via the same
# regex helper the chat-identity path uses (single source).
# get_user_display_name was a dead threadlocal stub that never existed,
# so owner_name was always None here. Fenced: a lookup miss/failure
# falls back to None, exactly as before.
owner_name = None
try:
_ud, _ = get_action_user_details(user_id=str(uid))
owner_name = extract_owner_name(_ud) or None
except Exception:
owner_name = None
announced = announce_presence(
adapter, room_id, str(uid), role,
owner_display_name=owner_name)
if not announced:
# Required announcement failed — leave to honor the policy.
try:
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
adapter.leave_room(room_id), loop).result(timeout=15)
else:
asyncio.run(adapter.leave_room(room_id))
except Exception:
logging.getLogger(__name__).exception("_handle_join_external_room_tool: swallowed Exception")
return (
f"Joined {platform}/{room_id} but could not post the "
f"required AI-presence announcement. Left the room to "
f"honor the privacy policy. Please try again."
)
# 5. Listen for objection
agent_id_for_room = f"{platform}:{room_id}"
def _on_detach(reason: str):
try:
if loop and loop.is_running():
asyncio.run_coroutine_threadsafe(
adapter.leave_room(room_id), loop).result(timeout=15)
else:
asyncio.run(adapter.leave_room(room_id))
except Exception as e:
logger.debug("Join_External_Room: leave_room failed: %s", e)
listen_for_objection(
adapter, room_id, str(uid), agent_id_for_room,
on_detach=_on_detach)
return (
f"Joined {platform}/{room_id} as {role}. I posted an "
f"AI-presence announcement and will leave automatically if "
f"any participant says 'no AI' or '/agent-out'."
)
except Exception as e:
return f"Join_External_Room error: {str(e)[:200]}"
def _handle_agentic_router_tool(input_text):
"""Tool handler: LLM detected a multi-step agentic task.
Called by the LangChain agent when a prompt needs multi-step execution.
Builds a plan using LLM-powered agent matching + plan generation,
sets thread-local flags that the /chat handler checks after get_ans() returns.
"""
try:
from integrations.agentic_router import build_agentic_plan_bounded
# Bounded in agentic_router, on core.subprocess_safe.call_bounded.
# This was `with ThreadPoolExecutor(...) as ex: ex.submit(...)
# .result(timeout=15)`, whose `with` exit joins the stuck worker, so
# the 15 s never released this turn (review F7, 2026-09-27).
plan = build_agentic_plan_bounded(input_text, PROMPTS_DIR)
if plan is None:
return f"I'll help you with: {input_text}. Let me work on this directly."
thread_local_data.set_agentic_routing(
task_description=plan['task_description'],
plan_steps=plan['steps'],
matched_agent_id=plan.get('matched_agent_id'),
)
# Build a human-readable summary for the LLM's response
steps_text = "\n".join(
f" {s['step_num']}. {s['description']}"
for s in plan['steps']
)
agent_note = ""
if plan.get('matched_agent_name'):
agent_note = f"\nMatched agent: {plan['matched_agent_name']} ({plan['matched_agent_source']})"
elif plan.get('requires_new_agent'):
agent_note = "\nNo existing agent matches — a new agent will be created if you approve."
return (
f"I've analyzed your request and prepared a plan:\n\n"
f"Task: {input_text}\n\n"
f"Steps:\n{steps_text}\n"
f"{agent_note}\n\n"
f"Would you like me to proceed with this plan?"
)
except Exception as e:
app.logger.error(f"Agentic router failed: {e}")
return f"I'll help you with: {input_text}. Let me work on this directly."
def _handle_request_resource(input_text: str) -> str:
"""Request_Resource: the agent needs a credential, API key or token.
hartos.ai_key_vault.request_credential_for_agent does the work, for this
tool and for core.agent_tools request_resource alike: an ask no person
could act on goes back to the agent; any other is asked on the owner's
consent card and the agent gets only the {{secret:NAME}} alias.
"""
from hartos.ai_key_vault import request_credential_for_agent
from integrations.social.consent_service import known_agent_id
return request_credential_for_agent(
input_text, agent_id=known_agent_id(thread_local_data.get_prompt_id()))
# Module-level cache for the google-search tool list. Process-wide
# memoization captured 2026-05-08 after observing
# ``get_tools(is_first=True)`` taking 33.4s in casual_conv hot path —
# the slow step is ``load_tools(["google-search"])`` which triggers
# the LangChain GoogleSearchAPIWrapper import chain + env-var probe
# for ``GOOGLE_API_KEY`` / ``GOOGLE_CSE_ID`` on every call. The
# return value is invariant per-process (env doesn't change at
# runtime, the wrapper class doesn't reload), so caching collapses
# the cost from 33s+ to ~zero on the second call onward. Design
# intent ("casual_conv → minimal tool set in ~2.5s") preserved —
# we just stop rebuilding the same answer on every chat turn.
_GOOGLE_SEARCH_TOOLS_CACHE: list | None = None
_GOOGLE_SEARCH_CACHE_LOCK = threading.Lock()
def _safe_load_google_search():
"""Load google-search tool, returning empty list if package is missing.
Memoized per-process — see ``_GOOGLE_SEARCH_TOOLS_CACHE`` above
for the full rationale. Result is invariant under runtime env
changes (env vars are read once at LangChain wrapper init); a
config change requires a process restart anyway.
In bundled/flat mode (NUNBA_BUNDLED=1 or HEVOLVE_NODE_TIER=flat),
the Google API key is expected to be missing — log at DEBUG instead
of WARNING so the log doesn't fill with noise every request. On
cloud/regional tiers, a missing key IS a warning worth surfacing.
"""
global _GOOGLE_SEARCH_TOOLS_CACHE
# Fast path — uncontended read after the first call.
if _GOOGLE_SEARCH_TOOLS_CACHE is not None:
return _GOOGLE_SEARCH_TOOLS_CACHE
with _GOOGLE_SEARCH_CACHE_LOCK:
# Double-check inside the lock so concurrent first-callers
# don't both pay the load_tools cost.
if _GOOGLE_SEARCH_TOOLS_CACHE is not None:
return _GOOGLE_SEARCH_TOOLS_CACHE
try:
result = load_tools(["google-search"])
except (ImportError, Exception) as e:
_bundled = os.environ.get('NUNBA_BUNDLED') or os.environ.get('HEVOLVE_NODE_TIER', 'flat') == 'flat'
if _bundled:
logging.debug(f"Google Search tool unavailable (expected in local mode): {e}")
else:
logging.warning(f"Google Search tool unavailable: {e}")
result = []
_GOOGLE_SEARCH_TOOLS_CACHE = result
return result
def _with_tool_logging(func, tool_name):
"""LangChain tool wrapper — delegates to the canonical core.tool_logging
(#114, single tool-log impl; no more parallel emit/error logic here).
LangChain ``Tool`` objects carry a ``.name`` distinct from ``func.__name__``
(-> name=tool_name for the correct UI label), and the agent_executor wants a
plain-string error rather than the autogen JSON envelope (-> plain_errors).
The per-tool UI stage emit + TOOL_LABELS lookup now live once in
core.tool_logging._emit_tool_call_stage.
"""
from core.tool_logging import log_tool_execution
return log_tool_execution(func, name=tool_name, plain_errors=True)
# Per-(user, prompt) cache for the casual_conv `is_first=True` tool
# list. Captured 2026-05-08 after observing 33.4s on every casual
# chat turn in the bundled env — the heavy steps inside this branch
# (LangChain ``load_tools(['google-search'])`` wrapper init + 30
# hardcoded Tool() objects + service_tool_registry +
# system_introspect + skill_registry + provider_gateway) have NO
# per-call variation once the process is up. Per-user memory tools
# bind to the live MemoryGraph object, so caching the tool function
# is safe: the function queries the graph at CALL time, not at
# build time, so newly added memories are still visible.
#
# Bounded to 32 entries (typical max sessions per Nunba install) +
# FIFO eviction so a long-running instance never grows unbounded.
# Design-intent preserved per
# `tts_engine.py:5722-5729` comment: casual_conv still gets the
# minimal tool set, just memoized so the build cost is paid ONCE
# per (user, prompt) pair instead of every chat turn.
_FIRST_TOOLS_CACHE: dict[str, list] = {}
_FIRST_TOOLS_CACHE_MAX = 32
_FIRST_TOOLS_CACHE_LOCK = threading.Lock()
# ── Process-level caches for slow registry probes ─────────────────────
# Each of these registries does heavy first-call work (Crawl4AI/AceStep
# health probes, langchain google-search wrapper init, system_introspect
# admin-API queries, skill_registry filesystem walk + GitHub probes,
# provider gateway enumeration). Together they account for ~80-100s
# of the 105s `get_tools(is_first=True)` cold-start latency observed
# on the 2026-05-13 09:55:23 IPL turn. Caching the result process-wide
# turns the cold path into a one-time pay; every subsequent (user,
# prompt) build pays only the per-user MemoryGraph open (~50ms).
# Filled lazily on first call (or eagerly by the boot warmer thread
# at startup — see `_warmup_tool_registries_in_background` below).
_SVC_TOOLS_CACHE = None
_SVC_TOOLS_LOCK = threading.Lock()
_INTROSPECT_TOOLS_CACHE = None
_INTROSPECT_TOOLS_LOCK = threading.Lock()
_SKILL_TOOLS_CACHE = None
_SKILL_TOOLS_LOCK = threading.Lock()
_PROVIDER_TOOLS_CACHE = None
_PROVIDER_TOOLS_LOCK = threading.Lock()
def _cached_service_tools():
"""Memoized service_tool_registry.get_langchain_tools()."""
global _SVC_TOOLS_CACHE
if _SVC_TOOLS_CACHE is not None:
return _SVC_TOOLS_CACHE
with _SVC_TOOLS_LOCK:
if _SVC_TOOLS_CACHE is not None:
return _SVC_TOOLS_CACHE
try:
from integrations.service_tools import service_tool_registry
result = list(service_tool_registry.get_langchain_tools())
except (ImportError, Exception):
result = []
_SVC_TOOLS_CACHE = result
return result
def _cached_introspect_tools():
"""Memoized system_introspect_tool.get_langchain_tools()."""
global _INTROSPECT_TOOLS_CACHE
if _INTROSPECT_TOOLS_CACHE is not None:
return _INTROSPECT_TOOLS_CACHE
with _INTROSPECT_TOOLS_LOCK:
if _INTROSPECT_TOOLS_CACHE is not None:
return _INTROSPECT_TOOLS_CACHE
try:
from integrations.service_tools.system_introspect_tool import (
get_langchain_tools as _get_introspect_tools,
)
result = list(_get_introspect_tools())
except (ImportError, Exception):
result = []
_INTROSPECT_TOOLS_CACHE = result
return result
def _cached_skill_tools():
"""Memoized skill_registry.get_langchain_tools()."""
global _SKILL_TOOLS_CACHE
if _SKILL_TOOLS_CACHE is not None:
return _SKILL_TOOLS_CACHE
with _SKILL_TOOLS_LOCK:
if _SKILL_TOOLS_CACHE is not None:
return _SKILL_TOOLS_CACHE
try:
from integrations.skills import skill_registry
result = list(skill_registry.get_langchain_tools())
except (ImportError, Exception):
result = []
_SKILL_TOOLS_CACHE = result
return result
def _cached_provider_tools():
"""Memoized integrations.providers.agent_tools.get_provider_tools()."""
global _PROVIDER_TOOLS_CACHE
if _PROVIDER_TOOLS_CACHE is not None:
return _PROVIDER_TOOLS_CACHE
with _PROVIDER_TOOLS_LOCK:
if _PROVIDER_TOOLS_CACHE is not None:
return _PROVIDER_TOOLS_CACHE
try:
from integrations.providers.agent_tools import get_provider_tools
result = list(get_provider_tools())
except Exception:
result = []
_PROVIDER_TOOLS_CACHE = result
return result
# ── Combined heavy-tools group (non-blocking lazy load) ────────────────
# The 3 suspects responsible for the 103-second silent gap during
# get_tools(is_first=True) on the 2026-05-13 09:55:23 IPL turn:
# 1. _safe_load_google_search() ← cold-import cascade
# (langchain_classic.agents.load_tools transitively pulls in
# google-api-python-client + httplib2 + oauth2client; on Windows
# with Defender scanning DLLs this can be 30-80s.)
# 2. _cached_skill_tools() ← potential GitHub discovery / fs walk
# 3. _cached_provider_tools() ← provider enumeration + connect probes
#
# Grouped here so get_tools NEVER blocks on them — even on the "warm"
# path the heavy cache is consulted with an O(1) read; if not yet
# filled, get_tools proceeds without these tools and a background
# daemon thread continues the build. Subsequent get_tools calls will
# pick them up once the build finishes.
_HEAVY_TOOLS_CACHE: list = []
_HEAVY_TOOLS_LOCK = threading.Lock()
_HEAVY_TOOLS_BUILD_KICKED = False
_HEAVY_TOOLS_READY = False
def _kick_heavy_tools_build_once():
"""Kick the heavy-tools background build if not already running.
Idempotent — safe to call multiple times from any thread."""
global _HEAVY_TOOLS_BUILD_KICKED
with _HEAVY_TOOLS_LOCK:
if _HEAVY_TOOLS_BUILD_KICKED:
return
_HEAVY_TOOLS_BUILD_KICKED = True
def _build():
global _HEAVY_TOOLS_CACHE, _HEAVY_TOOLS_READY
import time as _time
_start = _time.time()
tools = []
try:
tools += list(_safe_load_google_search() or [])
except Exception:
logging.getLogger(__name__).exception("_build: swallowed Exception")
_t_google = _time.time() - _start
try:
tools += list(_cached_skill_tools())
except Exception:
logging.getLogger(__name__).exception("_build: swallowed Exception")
_t_skill = _time.time() - _start - _t_google
try:
tools += list(_cached_provider_tools())
except Exception:
logging.getLogger(__name__).exception("_build: swallowed Exception")
_t_provider = _time.time() - _start - _t_google - _t_skill
with _HEAVY_TOOLS_LOCK:
_HEAVY_TOOLS_CACHE = tools
_HEAVY_TOOLS_READY = True
try:
app.logger.info(
"[HEAVY-TOOLS-LAZY] loaded %d tools "
"(google=%.1fs skill=%.1fs provider=%.1fs total=%.1fs)",
len(tools), _t_google, _t_skill, _t_provider,
_time.time() - _start,
)
except Exception:
logging.getLogger(__name__).exception("_build: swallowed Exception")
try:
import threading as _t
_t.Thread(target=_build, daemon=True,
name='heavy-tools-lazy-build').start()
except Exception:
# Spawn failed — reset the kicked flag so the next call retries.
with _HEAVY_TOOLS_LOCK:
_HEAVY_TOOLS_BUILD_KICKED = False
def _get_heavy_tools_nonblocking():
"""Return the heavy-tools cache contents — non-blocking, never waits.
Reader-only: the build is kicked EAGERLY at boot by
`_warmup_tool_registries_in_background()`, not lazily on first chat.
Chat path returns whatever's already loaded — [] if the background
builder hasn't finished yet, the full list once it has.
Replaces inline _safe_load_google_search / _cached_skill_tools /
_cached_provider_tools so the user never waits for the 80-100s
cold-start cascade.
"""
# Read without lock — list assignment is atomic in CPython, and a
# partially-built cache would still be a list of valid tools.
return _HEAVY_TOOLS_CACHE
def _warmup_tool_registries_in_background():
"""Pre-fill the 4 process-level tool caches in a daemon thread.
Hides the 80-100s cold-start latency that otherwise hits the FIRST
user chat turn. Call once at app boot; subsequent calls are no-ops
because the caches are already filled. Failures are swallowed
silently — the lazy on-call path will retry if a registry was
temporarily unavailable at boot.
"""
try:
import threading as _t
def _warm():
# Brief grace so the rest of bootstrap completes first.
try:
time.sleep(2)
except Exception:
logging.getLogger(__name__).exception("_warm: swallowed Exception")
# Fast registries — these probe at register-time, not
# iteration-time, so iterating them now is cheap. Kept
# synchronous in the warmer thread.
try:
_cached_service_tools()
except Exception:
logging.getLogger(__name__).exception("_warm: swallowed Exception")
try:
_cached_introspect_tools()
except Exception:
logging.getLogger(__name__).exception("_warm: swallowed Exception")
# The 3 heavy ones (google-search cold imports + skill
# registry GitHub/fs walk + provider gateway probes) — do
# NOT load them. Loading them eagerly burns the GIL for
# ~100s during boot (transformers _LazyModule recursion +
# registry probes); loading them lazily blocks the user's
# first chat for ~100s. Either way the net work is the
# same. Default: skip entirely.
#
# google_search is already available via top5_results in
# the inline labeled_tools list (hart_intelligence_entry:
# L4413), so no functionality loss for casual chat.
# skill_registry and provider_gateway are unused in this
# path on the flat/desktop tier.
#
# Opt-in for users who actually need the heavy group:
# $env:HEVOLVE_LOAD_HEAVY_TOOLS = "1"
if os.environ.get('HEVOLVE_LOAD_HEAVY_TOOLS', '').strip().lower() in ('1', 'true', 'yes', 'on'):
try:
_kick_heavy_tools_build_once()
app.logger.info(
"[TOOL-WARMUP] heavy group kicked (opt-in via "
"HEVOLVE_LOAD_HEAVY_TOOLS)")
except Exception:
logging.getLogger(__name__).exception("_warm: swallowed Exception")
try:
app.logger.info(
"[TOOL-WARMUP] fast tool registries ready "
"(svc=%d introspect=%d); heavy group "
"(google+skill+provider) skipped — opt-in via "
"HEVOLVE_LOAD_HEAVY_TOOLS",
len(_SVC_TOOLS_CACHE or []),
len(_INTROSPECT_TOOLS_CACHE or []),
)
except Exception:
logging.getLogger(__name__).exception("_warm: swallowed Exception")
_t.Thread(target=_warm, daemon=True, name='tool-registry-warmup').start()
except Exception as _w_err:
logging.getLogger(__name__).debug(
"tool-registry-warmup spawn skipped: %s", _w_err)
def get_tools(req_tool, is_first: bool = False, agent_tools=None):
"""The tool set for a turn.
``agent_tools`` False withholds the agent-work pair
(core.constants.AGENT_WORK_TOOL_NAMES) on either branch; the metered Hive
API asks for that (task #120). None, the default, reads the turn's own
answer from the request thread-local (thread_local_data.get_agent_tools,
set once by /chat from the body), so every caller of this turn agrees:
get_ans's two calls and CustomGPT._call's prompt rebuild on later steps,
which passes no flag and, while the flag travelled as a keyword,
re-listed both tools in the prompt after the first step (the review of
5a66a3308 measured it). Nothing is added by asking: an explicit False
withholds the pair on any turn, and an explicit True cannot bring it
back on a turn that declined (the review of df2474851 measured that it
did; no caller passes a value today)."""
turn_allows = thread_local_data.get_agent_tools()
agent_tools = turn_allows if agent_tools is None else (bool(agent_tools) and turn_allows)
if is_first:
# ── Timing instrumentation (#185) ─────────────────────────────
# The 2026-05-13 IPL turn showed `get_tools loaded` taking 118s
# despite the heavy-tools group being correctly skipped. Real
# culprit unknown; speculation cost us multiple iterations.
# Capture per-step deltas so the next test gives ground truth.
# Cheap (~12 time.time() calls); always-on for now, demote to
# debug after root cause is fixed.
_gt_t0 = time.time()
_gt_steps = [] # list of (label, elapsed_since_t0_seconds)
def _gt_mark(label):
_gt_steps.append((label, time.time() - _gt_t0))
# Per-(user, prompt) memoization — see _FIRST_TOOLS_CACHE
# docstring above. thread_local_data may be unset in
# degraded/test contexts; fall through to a non-cached
# build in that case.
_cache_key = None
try:
_uid = thread_local_data.get_user_id()
_pid = thread_local_data.get_prompt_id()
# The flag is part of the key: a set built without the agent-work
# pair must never be served to a turn that may use it, or back.
_cache_key = f"{_uid}:{_pid}:{'all' if agent_tools else 'no-agent-work'}"
except Exception:
_cache_key = None
if _cache_key and _cache_key in _FIRST_TOOLS_CACHE:
return _FIRST_TOOLS_CACHE[_cache_key]
_gt_mark('cache_check')
# Heavy tools (google-search + skill_registry + provider_gateway)
# come from the non-blocking heavy-tools cache filled by the boot
# warmer. If not ready yet, returns [] — chat path proceeds without
# them. Once the background build finishes, future first calls
# pick up the full list. Replaces the inline _safe_load_google_search
# call here AND the inline _cached_skill_tools / _cached_provider_tools
# calls further down.
tools = list(_get_heavy_tools_nonblocking())
_gt_mark('heavy_tools_nonblocking')
tool = []
# llm_math is None when its module-level init failed (logged there).
# Same guard shape as `chain` just below: the Calculator is optional,
# but evaluating `llm_math.run` on None kills the WHOLE tool list and
# with it every chat turn (live 2026-08-07: AttributeError on each
# /chat for a full 7h boot).
if llm_math is not None:
tool.append(labeled_tool(
name='Calculator',
func=llm_math.run,
description='Useful for when you need to answer questions about math.',
ui_label='Calculating…',
))
# Only add OpenAPI tool if chain is initialized
if chain is not None:
tool.append(labeled_tool(
name="OpenAPI_Specification",
func=chain.run,
description="Use this feature only when the user's request specifically pertains to one of the following scenarios:\
Image Creation: When a request involves generating an image using text, this feature should be engaged. The entire text prompt must be used as it is unless otherwise requested to enhance further detail of prompt for the image generation process. If additional enahancement is needed , enrich the prompt to image generation with greater detail for learning.\
Student Information: If a request is made for information regarding students, this functionality should be utilized to retrieve the necessary details.\
Query Available Books: When the user is inquiring about available books, this feature should be used to locate and provide information about the required texts.\
Any CRUD operation which is not a READ or anything related to curriculum should not use this tool, It is vital to ensure that the intent precisely falls within one of the above categories before engaging this functionality.\
Don't use this to create a custom curriculum for user",
ui_label='Calling the OpenAPI service…',
))
tool += [
labeled_tool(
name="FULL_HISTORY",
func=parsing_string,
description=f"""Utilize this tool exclusively when the information required predates the current day & pertains to the ongoing user query or when there is a need to recall certain things we spoke earlier. The necessary input for this tool comprises a list of values separated by commas.
The list should encompass a user-generated query, designated by user input text, a commencement date denoted as start_date, and an end date labeled as end_date. The start_date denotes the initiation date for the user information search and should consistently adhere to the ISO 8601 format. Meanwhile, the end_date, also conforming to the ISO 8601 format, signifies the conclusion date for the search.
In cases where the end_date is indeterminable, the current datetime should be employed. For example, if the objective is to retrieve a user's dialogue spanning from the preceding day up to the present day (assuming today's date is 2023-07-13T10:19:56.732291Z), the input would resemble: 'what we discussed about the project, 2023-07-12T10:19:56.00000Z, 2023-07-13T10:19:56.732291Z'. If query has any form of date or time by user, then start end datetime can be exact rather than till today for more accurate results. Remove any references to time based words (e.g. yesterday, today, datetimes, last year) since the date range you provide already accounts for that. e.g. if user has asked what did we discuss the day before yesterday then the text argument should just be empty since it does not have any named entity for fuzzy search followed by start and end datetime.
Strive to apply this tool judiciously for scenarios in which retrospective user information is imperative. If Full history tool response is present, forget other histories, the inputs should be meticulously arranged to facilitate the extraction of accurate and pertinent data within the specified timeframe. Never use this tool for what is the response to my last comment?
Remember whatever user query is regarding search history understand what user is asking about and rephrase it properly then send to tool. Before framing the final tool response from this tool consult corresponding created_at date time to give more accurate response""",
ui_label='Searching your message history…',
),
labeled_tool(
name="Text to image",
func=parse_text_to_image,
description="Based on user query generate visual representation of text. Extract prompt from user query and use it as input for function",
ui_label='Generating an image from text…',
),
labeled_tool(
name="Animate_Character",
func=parse_character_animation,
description='''Use this tool exclusively for animating the selected character or teacher as requested by the user. The user should specify their animation request in a query, such as 'Show me in a spacesuit' or 'Animate yourself as a cartoon standing in front of the Taj Mahal.' This tool handles requests involving animating a pre-selected character and should not be used for general image generation tasks. For example, use it for 'Show me a picture of yourself dancing in the rain' but not for 'Generate an image of a sunset.' input''',
ui_label='Animating your character…',
),
labeled_tool(
name="Image_Inference_Tool",
func=parse_image_to_text,
description='''When a user provides a query containing an image download URL and a related question about that image, utilize this tool for support. Your objective is to extract both the image URL and the user's inquiry or prompt pertaining to that image from their query, and then convert these elements into comma seperated string. The format should be as follows: "image_url, user_query".
''',
ui_label='Analyzing the image…',
),
labeled_tool(
name="Data_Extraction_From_URL",
func=parse_link_for_crwalab,
description='''
Your task is to extract a URL and its type (either 'pdf' or 'website') from a user's query. Upon receiving a query that contains a URL and a specified URL type, you are to use a tool designed for this purpose. The objective is to accurately identify both the URL and its type from the query. Once identified, these elements should be formatted into a comma-separated string, adhering to the format: "url, url_type".
''',
ui_label='Extracting data from URL…',
),
# Direct google-search inline — bypasses langchain_classic.agents.load_tools
# which on first call triggers the transformers _LazyModule re-entry
# recursion (see lines 95-157 docstring). `top5_results` is defined
# in this same file (L6107) — Python resolves the symbol at call
# time, so forward-referencing it here is safe. Without this entry
# the casual_conv langchain agent has no web-search affordance,
# forcing refusals on any "fetch live data" query (2026-05-13 IPL
# turn 301eeed0 forensic).
labeled_tool(
name="google_search",
func=top5_results,
description="Search Google for recent results and retrieve URLs that are suitable for web crawling. Returns the top 5 result URLs + snippets. Use whenever you need live or recent information from the web — sports standings, current news, prices, schedules, anything time-sensitive. Always present source URLs in the response as HTML anchor tags for attribution. Input: the search query as a string.",
ui_label='Searching the web…',
),
labeled_tool(
name="User_details_tool",
func=parse_user_id,
description="If a request is made for information regarding students or users, this functionality should be utilized to retrieve the necessary details. input for this api should Always be current user_id. Except current user id you should say you cannot have access other user's details.",
ui_label='Looking up user details…',
),
labeled_tool(
name="List_Agents",
func=_parse_list_agents,
description=_LIST_AGENTS_TOOL_DESCRIPTION,
ui_label='Listing your agents…',
),
labeled_tool(
name="Visual_Context_Camera",
func=parse_visual_context,
description="To see user or if there is a need to look at user camera feed for vision and understanding scene, visual question answering, seeing user, recognise visual objects and activity then this should be utilised. Input to this tool function should be the user query/input. Only if last 16 seconds Visual Context information is present & is enough, then use that to craft a better creative, better, cohesive, correlated , summarised natural response, format this tool response togather with Previous 15 minutes Visual Context information if you are seeing the scene via videocall from the other end. If there are more than 1 person try to give an identity to each across frames to track the subjects through time by framing the tool input accordingly.",
ui_label='Looking through camera…',
),
labeled_tool(
name="Visual_Context_Watcher",
func=_handle_visual_watcher_tool,
description=(
"Register a visual or audio trigger: continuously watch what the user is doing "
"via camera or listen to what they say, and perform an action when a condition is met. "
"Input format: 'CONDITION: | ACTION: | TTL: '. "
"Optional: '| MODALITY: visual|audio|both' (defaults to visual). "
"Example: 'CONDITION: user raises hand | ACTION: say banana | TTL: 30'. "
"Example: 'CONDITION: user mentions their dog | ACTION: remind them about vet appointment | TTL: 60 | MODALITY: audio'. "
"The watcher runs in the background and fires the action whenever the condition "
"is detected. TTL auto-expires the watcher."
),
ui_label='Setting up a visual watcher…',
),
labeled_tool(
name=CREATE_AGENT_TOOL_NAME,
func=_handle_create_agent_tool,
description=_CREATE_AGENT_TOOL_DESCRIPTION,
ui_label='Starting agent creation…',
),
labeled_tool(
name="Request_Resource",
func=_handle_request_resource,
description=REQUEST_RESOURCE_DESCRIPTION,
ui_label='Requesting a resource…',
),
labeled_tool(
name="Suggest_Share_Worthy_Content",
func=_suggest_share_worthy_content,
description=(
"Use this tool when the user asks about content worth sharing, what to share, "
"or when you want to proactively suggest high-engagement posts that deserve "
"wider reach. Finds posts with strong community engagement (many upvotes and "
"comments) but low share count, and suggests them for sharing. "
"Input can be any text — it is not used for filtering."
),
ui_label='Finding share-worthy content…',
),
labeled_tool(
name="Observe_User_Experience",
func=_observe_user_experience,
description=(
"Record a user experience observation. Input: JSON with event, page, "
"duration_ms, outcome. Used for self-improvement and understanding user "
"behavior patterns."
),
ui_label='Recording an observation…',
),
labeled_tool(
name="Self_Critique_And_Enhance",
func=_self_critique_and_enhance,
description=(
"Review past agent suggestions and user behavior observations to improve "
"future recommendations. Input: topic or area to critique. Helps the agent "
"learn from its own interactions."
),
ui_label='Reflecting on past suggestions…',
),
labeled_tool(
name=AGENTIC_ROUTER_TOOL_NAME,
func=_handle_agentic_router_tool,
description=(
"Use when the user's request requires multi-step execution such as building "
"an application, writing code, conducting research, creating a marketing "
"campaign, or any complex task that cannot be answered in a single response. "
"Input: the user's full request describing what they want accomplished. "
"Output: a structured plan with steps. Do NOT use for simple questions, "
"greetings, or tasks that can be answered directly."
),
ui_label='Planning the next steps…',
),
labeled_tool(
name="Shell_Command",
func=_handle_shell_command_tool,
description=(
"Run a single shell / PowerShell / bash command on the user's "
"computer. ALWAYS prefer this over Computer_Action when the task "
"is expressible as a command — it's deterministic, 100ms instead "
"of 60+ seconds of VLM grounding, and doesn't need to see the "
"screen. Use Shell_Command for: launching apps (notepad, calc, "
"chrome, explorer C:\\path), opening files (notepad file.txt), "
"listing directories (dir, ls), system info (systeminfo, "
"Get-Process), creating/deleting files, running scripts, git "
"operations, package installs, etc. Input formats: plain command "
"string ('notepad hello.txt'), 'powershell: Get-Process | Select "
"-First 5' to force PowerShell, 'bash: ls -la ~' to force bash. "
"Destructive patterns (format C:, rm -rf /, fork bomb, etc.) are "
"denied by a built-in safety list. 30-second timeout. Output "
"truncated to 4000 chars. For long-running coding work use "
"Execute_Coding_Task instead."
),
ui_label='Running a command…',
),
labeled_tool(
name="Computer_Action",
func=_handle_computer_action_tool,
description=(
"Execute a GUI action on the user's computer: click buttons, "
"type text, scroll, interact with visible UI elements that can "
"ONLY be reached through the graphical interface. Uses a VLM to "
"identify what to click, so it's ~10-60 seconds per task. "
"IMPORTANT: before picking this tool, ask yourself whether "
"Shell_Command could do the same job — launching apps, opening "
"files, running scripts, changing settings, etc. are almost "
"always faster and more reliable via Shell_Command. Reserve "
"Computer_Action for tasks that genuinely require visual "
"grounding: clicking a specific button inside an already-running "
"app's UI, picking a visible option from a GUI menu, dragging "
"something, etc. Input: natural language instruction (e.g. "
"'click the Save button in the open document', 'select the "
"second item in the dropdown')."
),
ui_label='Acting on your screen…',
),
labeled_tool(
name="Computer_Screenshot",
func=_handle_screenshot_tool,
description=(
"Take a screenshot of the user's screen and describe what you see. "
"Use when you need to understand what's on screen before acting. "
"Input: question about the screen (e.g. 'what apps are open?')."
),
ui_label='Taking a screenshot…',
),
labeled_tool(
name="Request_Camera_Access",
func=_request_capability_consent,
description=(
"Request user permission to access their camera for visual "
"understanding. Use when you need to see the user but camera is "
"not active. Input: reason for needing camera access."
),
ui_label='Requesting camera access…',
),
labeled_tool(
name="Request_Screen_Access",
func=_request_screen_consent,
description=(
"Request user permission to see their screen for computer use. "
"Use when you need to see the screen but screen sharing is not "
"active. Input: reason for needing screen access."
),
ui_label='Requesting screen access…',
),
labeled_tool(
name="Connect_Channel",
func=_handle_connect_channel_tool,
description=(
"Connect / register a messaging channel like WhatsApp, Telegram, "
"Discord, Slack, Email, SMS, Teams, etc. so the user can chat with "
"the agent outside Nunba. Use this tool whenever the user asks to "
"'connect ', 'add ', 'link my ', "
"'set up ', 'hook up ', or similar — especially "
"for any of the 31 supported channels. Input: the channel name "
"alone (e.g. 'whatsapp') to start setup and get the list of "
"required credentials, OR 'channel_name ' to register "
# The doubled braces {{...}} are REQUIRED — LangChain's ReAct
# prompt template passes every tool description through
# str.format(), which treats literal { and } as placeholder
# markers. A single `{"bot_token":"..."}` in this string
# raises ValueError: Missing some input keys: {'"bot_token"'}
# at prompt-construction time and crashes /chat before any
# LLM call. Doubling escapes them to literals. See
# langchain.log 2026-04-11 22:46:01 for the crash.
"with credentials (e.g. 'telegram {{\"bot_token\":\"123:ABC\"}}'). "
"For WhatsApp specifically, passing just 'whatsapp' shows a QR "
"code to scan; if the user can't scan (WhatsApp is on the same "
"phone) or asks to link with their number, pass 'whatsapp "
"' to send a pairing code instead. "
"Do NOT ask the user for credentials first — "
"call this tool with just the channel name and it will tell you "
"what's needed."
),
ui_label='Connecting channel…',
),
labeled_tool(
name="Invite_Friend",
func=_handle_invite_friend_tool,
description=(
"Generate a shareable Nunba invite link the user can send to a "
"friend, colleague, or family member so they can join Nunba "
"and (optionally) credit the inviter as their referrer. Use "
"whenever the user says things like 'invite a friend', 'share "
"Nunba with my colleague', 'give me an invite link', 'how do "
"I refer people', 'send my friend an invite', or similar. "
"Input: optional short context describing who the invite is "
"for (e.g. 'work friend', 'family'), or empty string for a "
"generic shareable link. The tool returns a URL the user can "
"paste into any channel; a floating share-card also appears "
"in chat with a one-click Copy button."
),
ui_label='Generating an invite link…',
),
labeled_tool(
name="Join_External_Room",
func=_handle_join_external_room_tool,
description=(
"Join an external room/channel — Discord channel, Slack "
"channel, Telegram super-group, Matrix room, Teams meeting, "
"WhatsApp group, etc. — on behalf of the user as an AI "
"co-pilot, note-taker, participant, silent observer, or "
"writer. Use whenever the user says things like 'join my "
"Discord audio room', 'attend my Teams meet', 'take notes "
"in the WhatsApp family group', 'co-pilot my Slack channel', "
"or similar. Always asks the user for consent first via a "
"Liquid UI prompt, then announces its presence in the room "
"(per HIVE AI MISSION — never silent). Leaves automatically "
"if any participant says 'no AI' / '/agent-out'. Input: "
"either ' ' or "
"' ' or a full JSON dict "
"{{\"platform\":..., \"room\":..., \"role\":...}}. Roles: "
"co_pilot (default), participant, note_taker, "
"silent_observer, writer."
),
ui_label='Joining the room…',
),
labeled_tool(
name="Navigate_App",
func=_handle_navigate_app_tool,
description=(
"Open a different page of the Nunba app for the user. Use this "
"whenever the user says things like 'take me to the social feed', "
"'open model management', 'show me the channels page', 'go to "
"admin', 'show the marketplace', 'open games', etc. Input: the "
"destination in natural language (e.g. 'model management' or "
"'social feed'). The tool resolves it against the app's page "
"registry and makes the frontend open that route — you don't "
"need to give a URL, just say the page. After calling this tool, "
"tell the user you're opening that page in a single short sentence."
),
ui_label='Opening the requested page…',
),
labeled_tool(
name="List_Pending_Actions",
func=_handle_list_pending_actions_tool,
description=(
"List the user's upcoming scheduled messages, reminders, and "
"background jobs. Use when the user asks 'what's next', "
"'what do I have scheduled', 'any reminders for today', "
"'what's coming up', 'show my upcoming tasks', etc. "
"Input: optional filter hint ('today', 'this week', "
"'tomorrow') or empty string for all pending. Returns a "
"plain-text list with ISO timestamps — reformat for the "
"user in a natural style. Pulls from both the durable "
"ScheduledMessageManager (reminders, channel messages) "
"and the live apscheduler job store (periodic tasks)."
),
ui_label='Listing pending actions…',
),
]
_gt_mark('inline_labeled_tools')
# Service Tools: Add HTTP microservice tools (Crawl4AI, AceStep, etc.)
# Now memoized at module level — see _cached_service_tools. Eliminates
# the per-(user, prompt) probe cost (Crawl4AI/AceStep health checks)
# observed at ~30s/call on cold first turn.
tool += list(_cached_service_tools())
_gt_mark('cached_service_tools')
# System Introspection Tools: agent self-awareness — GPU tier,
# active models, TTS backend, boot-decision rationale. Lets the
# LLM answer "what model is running?", "why is speculation off?",
# "do I have a GPU?" from real live admin-API data instead of
# hallucinating. Module-level cached.
tool += list(_cached_introspect_tools())
_gt_mark('cached_introspect_tools')
# HART Skills: Ingest agent skills (Claude Code, Markdown, GitHub).
# Moved into the heavy-tools group (loaded eagerly at boot, non-
# blocking on chat path) — see _get_heavy_tools_nonblocking above.
# Already included in the `tools` list at the top of this branch.
# Memory Tools: Add MemoryGraph-backed tools (remember, recall, backtrace)
try:
user_id = thread_local_data.get_user_id()
prompt_id = thread_local_data.get_prompt_id()
_gt_mark('memory_get_ids')
graph = _get_or_create_graph(user_id, prompt_id)
_gt_mark('memory_get_or_create_graph')
if graph:
from integrations.channels.memory.agent_memory_tools import (
create_memory_tools, create_langchain_tools,
)
_gt_mark('memory_tools_import')
session_id = f"{user_id}_{prompt_id}" if prompt_id else str(user_id)
mem_tools_dict = create_memory_tools(graph, str(user_id), session_id)
_gt_mark('memory_create_memory_tools')
lc_mem_tools = create_langchain_tools(mem_tools_dict)
_gt_mark('memory_create_langchain_tools')
tool += lc_mem_tools
except Exception:
_gt_mark('memory_exception')
pass # Non-blocking — memory tools are optional
tools += tool
if not agent_tools:
tools = _without_agent_work_tools(tools)
# Provider gateway tools (Cloud_LLM, Generate_Image, etc.).
# Moved into the heavy-tools group (loaded eagerly at boot, non-
# blocking on chat path) — already included in `tools` at the top
# of this branch via _get_heavy_tools_nonblocking().
# Wrap all tool functions with logging
for t in tools:
if hasattr(t, 'func') and callable(t.func):
t.func = _with_tool_logging(t.func, t.name)
elif hasattr(t, '_run') and callable(t._run):
t._run = _with_tool_logging(t._run, t.name)
_gt_mark('wrap_logging_loop')
# Memoize the wrapped result for this (user, prompt) so the
# next casual_conv chat turn returns in micro-seconds instead
# of re-burning the 30s registry/skills/provider build.
# Bounded FIFO eviction keeps memory flat under heavy
# multi-session usage; the per-user MemoryGraph queried by
# cached memory-tool functions stays live (lookups happen at
# call time, not build time).
if _cache_key:
with _FIRST_TOOLS_CACHE_LOCK:
if (
len(_FIRST_TOOLS_CACHE) >= _FIRST_TOOLS_CACHE_MAX
and _cache_key not in _FIRST_TOOLS_CACHE
):
_FIRST_TOOLS_CACHE.pop(next(iter(_FIRST_TOOLS_CACHE)))
_FIRST_TOOLS_CACHE[_cache_key] = tools
_gt_mark('cache_save')
# Emit timing breakdown — DELTA between consecutive marks so each
# row shows that step's cost, not cumulative. Output keyed by
# `[GET_TOOLS_TIMING]` for easy grep.
try:
_prev = 0.0
_parts = []
for _label, _t in _gt_steps:
_delta = _t - _prev
_parts.append(f"{_label}={_delta:.2f}s")
_prev = _t
app.logger.info(
"[GET_TOOLS_TIMING] total=%.2fs tools=%d steps: %s",
_gt_steps[-1][1] if _gt_steps else 0.0,
len(tools),
" ".join(_parts),
)
except Exception:
logging.getLogger(__name__).exception("get_tools: swallowed Exception")
return tools
else:
tools_dict = {1: 'google_search', 2: 'Calculator', 3: 'OpenAPI_Specification', 4: 'FULL_HISTORY', 5: 'Text to image',
6: 'Image_Inference_Tool', 7: 'Data_Extraction_From_URL', 8: 'User_details_tool', 9: 'Visual_Context_Camera'}
tool_desc = {
'google_search': '''Search Google for recent results and retrieve URLs that are suitable for web crawling. Ensure that the search responses include the source URL from which the data was extracted. Always present this URL in the response as an HTML anchor tag. This approach ensures clear attribution and easy navigation to the original source for each piece of extracted information. Give urls for the source''',
'Calculator': '''Useful for when you need to answer questions about math.''',
'OpenAPI_Specification': '''Use this feature only when the user's request specifically pertains to one of the following scenarios:\
Image Creation: When a request involves generating an image using text, this feature should be engaged. The entire text prompt must be used as it is unless otherwise requested to enhance further detail of prompt for the image generation process. If additional enahancement is needed , enrich the prompt to image generation with greater detail for learning.\
Student Information: If a request is made for information regarding students, this functionality should be utilized to retrieve the necessary details.\
Query Available Books: When the user is inquiring about available books, this feature should be used to locate and provide information about the required texts.\
Any CRUD operation which is not a READ or anything related to curriculum should not use this tool, It is vital to ensure that the intent precisely falls within one of the above categories before engaging this functionality.\
Don't use this to create a custom curriculum for user''',
'FULL_HISTORY': '''Utilize this tool exclusively when the information required predates the current day & pertains to the ongoing user query or when there is a need to recall certain things we spoke earlier. The necessary input for this tool comprises a list of values separated by commas.
The list should encompass a user-generated query, designated by user input text, a commencement date denoted as start_date, and an end date labeled as end_date. The start_date denotes the initiation date for the user information search and should consistently adhere to the ISO 8601 format. Meanwhile, the end_date, also conforming to the ISO 8601 format, signifies the conclusion date for the search.
In cases where the end_date is indeterminable, the current datetime should be employed. For example, if the objective is to retrieve a user's dialogue spanning from the preceding day up to the present day (assuming today's date is 2023-07-13T10:19:56.732291Z), the input would resemble: 'what we discussed about the project, 2023-07-12T10:19:56.00000Z, 2023-07-13T10:19:56.732291Z'. If query has any form of date or time by user, then start end datetime can be exact rather than till today for more accurate results. Remove any references to time based words (e.g. yesterday, today, datetimes, last year) since the date range you provide already accounts for that. e.g. if user has asked what did we discuss the day before yesterday then the text argument should just be empty since it does not have any named entity for fuzzy search followed by start and end datetime.
Strive to apply this tool judiciously for scenarios in which retrospective user information is imperative. If Full history tool response is present, forget other histories, the inputs should be meticulously arranged to facilitate the extraction of accurate and pertinent data within the specified timeframe. Never use this tool for what is the response to my last comment?
Remember whatever user query is regarding search history understand what user is asking about and rephrase it properly then send to tool. Before framing the final tool response from this tool consult corresponding created_at date time to give more accurate response''',
'Text to image': '''Based on user query generate visual representation of text. Extract prompt from user query and use it as input for function''',
'Image_Inference_Tool': '''When a user provides a query containing an image download URL and a related question about that image, utilize this tool for support. Your objective is to extract both the image URL and the user's inquiry or prompt pertaining to that image from their query, and then convert these elements into comma seperated string. The format should be as follows: "image_url, user_query".''',
'Data_Extraction_From_URL': '''Your task is to extract a URL and its type (either 'pdf' or 'website') from a user's query. Upon receiving a query that contains a URL and a specified URL type, you are to use a tool designed for this purpose. The objective is to accurately identify both the URL and its type from the query. Once identified, these elements should be formatted into a comma-separated string, adhering to the format: "url, url_type".''',
'User_details_tool': '''If a request is made for information regarding students or users, this functionality should be utilized to retrieve the necessary details. input for this api should Always be current user_id. Except current user id you should say you cannot have access other user's details.''',
'Visual_Context_Camera': '''This tool captures the user's visual context during a video call, providing real-time captions. Use it for visual question answering, scene understanding, recognizing objects, activities, & monitoring the user. Input will be the user's input/query. If the last 16 seconds of visual context are available and sufficient, it crafts a creative, cohesive response. If not, inform the user of the glitch accessing the current camera feed and guess using the Last_5_Minutes_Visual_Context. Ensure responses are natural, avoiding lists of captions, and format them as if you are seeing the user scene via video call. Analyze the current tool response and previous visual context captions to recognize user activities and infer actions from multiple frames. If the user requests continuous narration without active input, adapt the response to include past, present, and future tenses for dynamic and contextually aware commentary.'''
}
tools_func = {
'google_search': top5_results,
'FULL_HISTORY': parsing_string,
'Text to image': parse_text_to_image,
'Image_Inference_Tool': parse_image_to_text,
'Data_Extraction_From_URL': parse_link_for_crwalab,
'User_details_tool': parse_user_id,
'Visual_Context_Camera': parse_visual_context
}
# Calculator/OpenAPI only when their chains initialized — same guard
# shape; llm_math None = failed boot init (logged at module level).
if llm_math is not None:
tools_func['Calculator'] = llm_math.run
# Only add OpenAPI_Specification to tools_func if chain is initialized
if chain is not None:
tools_func['OpenAPI_Specification'] = chain.run
if req_tool == "google_search":
req_tool = "Google Search Snippets"
# `req_tool in tools_func` guards the tools_func[req_tool] lookup two
# lines down: with llm_math None a Calculator req_tool would KeyError
# here; instead it takes the else-branch (google-search only), which
# is the same degraded-but-alive behaviour as the tool list above.
# When every chain initialized, the maps agree and this is a no-op.
if req_tool is not None and req_tool in tools_dict.values() \
and req_tool in tools_func:
tool_description = tool_desc[req_tool]
tool_func = tools_func[req_tool]
req_tool_from_user = [
Tool(
name=req_tool,
func=tool_func,
description=tool_description
)
]
tools = _safe_load_google_search()
tools += req_tool_from_user
else:
tool_description = ""
tool_func = ""
tools = _safe_load_google_search()
# tools += req_tool_from_user
tool = []
# Same None-guard as the first-tools branch above — see the comment
# there and at the module-level init.
if llm_math is not None:
tool.append(Tool(
name='Calculator',
func=llm_math.run,
description='Useful for when you need to answer questions about math.'
))
# Only add OpenAPI tool if chain is initialized
if chain is not None:
tool.append(Tool(
name="OpenAPI_Specification",
func=chain.run,
description="Use the specialized feature for image generation, student information retrieval, and querying available books, while avoiding its use for non-READ CRUD operations or custom curriculum creation.",
))
tool += [
Tool(
name="FULL_HISTORY",
func=parsing_string,
description=f"""Use the tool for retrieving historical user information within a specified timeframe, including user-generated queries, start and end dates in ISO 8601 format, and carefully rephrase queries related to search history for accurate responses, avoiding use for responses to the last comment."""
),
Tool(
name="Text to image",
func=parse_text_to_image,
description="Based on user query generate visual representation of text. Extract prompt from user query and use it as input for function"
),
Tool(
name="Animate_Character",
func=parse_character_animation,
description='''Use this tool exclusively for animating the selected AI character or teacher as requested by the user; it is not intended for general requests or for animating random images or individuals other than AI teacher avatars. The user should specify their animation request in a query, e.g. 'Show me yourself in a spacesuit' or 'Animate yourself as a person riding a bike.' Once the request is made, the tool will generate the animation and return an URL link to the user that directs them to the animated image. This tool should not be used for general image generation tasks that don't pertain to animating the user's chosen character or teacher. For example, if a user queries 'Show me dancing in the rain,' and they have previously selected a specific character or teacher, the tool should be used to generate this animated scenario. However, if the user's request is something like 'Generate an image of a sunset,' which does not directly involve animating the selected character or teacher, then this tool should not be used.'''
),
Tool(
name="Image_Inference_Tool",
func=parse_image_to_text,
description='''Utilize the tool to extract and format both the image URL and the user's inquiry from a query containing an image download URL into a comma-separated string: "image_url, user_query".'''
),
Tool(
name="Data_Extraction_From_URL",
func=parse_link_for_crwalab,
description='''Utilize the designated tool to extract and format a URL and its type (either 'pdf' or 'website') from a user's query into a comma-separated string: "url, url_type".'''
),
Tool(
name="User_details_tool",
func=parse_user_id,
description="Utilize this functionality to retrieve information about students or users, requiring the current user_id as the only acceptable input; access to other user details is not allowed."
),
Tool(
name="List_Agents",
func=_parse_list_agents,
description=_LIST_AGENTS_TOOL_DESCRIPTION,
),
Tool(
name="Visual_Context_Camera",
func=parse_visual_context,
description="This tool captures the user's visual context during a video call, providing real-time captions. Use it for visual question answering, scene understanding, recognizing objects, activities, & monitoring the user. Input will be the user's input/query. If the last 16 seconds of visual context are available and sufficient, it crafts a creative, cohesive response. If not, inform the user of the glitch accessing the current camera feed and guess using the Last_5_Minutes_Visual_Context. Ensure responses are natural, avoiding lists of captions, and format them as if you are seeing the user scene via video call. Analyze the current tool response and previous visual context captions to recognize user activities and infer actions from multiple frames. If the user requests continuous narration without active input, adapt the response to include past, present, and future tenses for dynamic and contextually aware commentary."
),
Tool(
name=CREATE_AGENT_TOOL_NAME,
func=_handle_create_agent_tool,
description=_CREATE_AGENT_TOOL_DESCRIPTION,
),
Tool(
name="Request_Resource",
func=_handle_request_resource,
description=REQUEST_RESOURCE_DESCRIPTION,
),
Tool(
name="Suggest_Share_Worthy_Content",
func=_suggest_share_worthy_content,
description=(
"Use this tool when the user asks about content worth sharing, what to share, "
"or when you want to proactively suggest high-engagement posts that deserve "
"wider reach. Finds posts with strong community engagement (many upvotes and "
"comments) but low share count, and suggests them for sharing. "
"Input can be any text — it is not used for filtering."
),
),
Tool(
name="Observe_User_Experience",
func=_observe_user_experience,
description=(
"Record a user experience observation. Input: JSON with event, page, "
"duration_ms, outcome. Used for self-improvement and understanding user "
"behavior patterns."
),
),
Tool(
name="Self_Critique_And_Enhance",
func=_self_critique_and_enhance,
description=(
"Review past agent suggestions and user behavior observations to improve "
"future recommendations. Input: topic or area to critique. Helps the agent "
"learn from its own interactions."
),
),
Tool(
name=AGENTIC_ROUTER_TOOL_NAME,
func=_handle_agentic_router_tool,
description=(
"Use when the user's request requires multi-step execution such as building "
"an application, writing code, conducting research, creating a marketing "
"campaign, or any complex task that cannot be answered in a single response. "
"Input: the user's full request describing what they want accomplished. "
"Output: a structured plan with steps. Do NOT use for simple questions, "
"greetings, or tasks that can be answered directly."
),
),
]
final_tool = []
for new_tool in tool:
if new_tool not in tools:
final_tool.append(new_tool)
tools += final_tool
if not agent_tools:
tools = _without_agent_work_tools(tools)
# Wrap all tool functions with logging
for t in tools:
if hasattr(t, 'func') and callable(t.func):
t.func = _with_tool_logging(t.func, t.name)
elif hasattr(t, '_run') and callable(t._run):
t._run = _with_tool_logging(t._run, t.name)
# Return the Tool objects, symmetric with the is_first=True branch.
# This branch used to return a rendered "> name: description" STRING
# (shaped for the CustomGPT splice, which now renders
# at its own call site). get_ans passes this value to
# ConversationalChatAgent.create_prompt + CustomAgentExecutor, and
# the string crashed both: live 2026-08-24 08:23:44, hie:7608
# "AttributeError: 'str' object has no attribute 'name'" — every
# casual_conv=False turn died there and the tool-less '_tier':
# 'direct' fallback answered. Unreachable dead code until the
# classifier override (15ed5874) made this branch live; all 3
# logged crashes are the 3 override-fired turns.
return tools
# custom GPT
# Language dict — canonical in core/constants.py
from core.constants import SUPPORTED_LANG_DICT # noqa: E402
from core.labeled_tool import labeled_tool, generic_label # noqa: E402 # #508
from core.peer_link.crossbar_publish import publish_chat_stage # noqa: E402 # #508
# ---------------------------------------------------------------------------
# G12: Parallel SSM student executor shared across CustomGPT instances.
# ---------------------------------------------------------------------------
# llama.cpp (teacher) is reached via pooled_post to GPT_API / DRAFT_GPT_API.
# HevolveAI's SSM student previously never ran on this path — only the
# in-process LearningLLMProvider.create_chat_completion() branch invoked it,
# which HARTOS' CustomGPT bypasses entirely. We now kick off the SSM
# forward pass in parallel with the teacher HTTP call. After the teacher
# reply arrives we opportunistically pair the two for distillation.
#
# Failure modes (all silent — never block the teacher path):
# * Bridge unavailable → student_future is None
# * In-process mode off → predict_student returns None
# * SSM forward raises → predict_student returns None
# * SSM exceeds collect timeout → skip distillation, return teacher only
import concurrent.futures as _g12_cf
_g12_ssm_executor: Optional[_g12_cf.ThreadPoolExecutor] = None
_g12_ssm_executor_lock = threading.Lock()
def _g12_get_executor() -> Optional[_g12_cf.ThreadPoolExecutor]:
global _g12_ssm_executor
if _g12_ssm_executor is not None:
return _g12_ssm_executor
with _g12_ssm_executor_lock:
if _g12_ssm_executor is None:
if os.environ.get('HEVOLVE_PARALLEL_SSM', '1') == '0':
return None
try:
_g12_ssm_executor = _g12_cf.ThreadPoolExecutor(
max_workers=2, thread_name_prefix='g12_ssm_student')
atexit.register(
lambda: _g12_ssm_executor.shutdown(wait=False)
if _g12_ssm_executor else None)
except Exception:
_g12_ssm_executor = None
return _g12_ssm_executor
def _g12_kick_off_student(prompt: str):
"""Submit a student SSM forward pass; return a Future or None."""
if not prompt or len(prompt) < 1:
return None
ex = _g12_get_executor()
if ex is None:
return None
try:
from integrations.agent_engine.world_model_bridge import (
get_world_model_bridge,
)
bridge = get_world_model_bridge()
except Exception:
return None
def _worker(_bridge=bridge, _prompt=prompt):
try:
return _bridge.predict_student(_prompt)
except Exception:
return None
try:
return ex.submit(_worker)
except RuntimeError:
# Executor shut down (e.g., during interpreter teardown).
return None
def _g12_finalize(prompt: str, teacher_response: str, student_future) -> None:
"""Schedule distillation when the student future completes — NEVER block
the teacher return.
PERF-3 (audit): this used to ``student_future.result(timeout=0.5)`` —
blocking the user-reply path up to 0.5s per LLM iteration waiting on the
SSM student's forward pass. That work is distillation TRAINING DATA; it is
never needed to answer the user. We now attach a completion callback
instead of joining, so the reply returns immediately (finally matching this
function's long-standing "fire-and-forget" contract) and the
teacher/student pair is recorded through the SAME canonical sink
(world_model_bridge.record_teacher_student_pair) the moment the student
finishes — even past 0.5s, so we record MORE pairs than the old
drop-on-timeout path, at zero reply-latency cost.
"""
if student_future is None or not teacher_response:
return
def _record_when_ready(fut) -> None:
# Runs on the student executor's thread when the SSM pass completes (or
# immediately, on the caller's thread, if already settled). Either way
# this is the fast record only — never the 0.5s wait. record_* is
# already reachable concurrently across chat turns, so no new
# thread-safety surface is introduced here.
try:
student = fut.result()
except Exception:
return
if not student or not isinstance(student, dict):
return
s_text = student.get('response')
if not s_text:
return
try:
from integrations.agent_engine.world_model_bridge import (
get_world_model_bridge,
)
bridge = get_world_model_bridge()
bridge.record_teacher_student_pair(
prompt=prompt,
teacher_response=teacher_response,
student_response=str(s_text),
student_action=student.get('action_tensor'),
)
except Exception:
logging.getLogger(__name__).exception("_record_when_ready: swallowed Exception")
try:
student_future.add_done_callback(_record_when_ready)
except Exception:
# Future already settled / executor torn down — best-effort, never raise.
logging.getLogger(__name__).exception("_g12_finalize: swallowed Exception")
# First words of TEMPLATE_TOOL_RESPONSE's post-observation instruction (the
# LangChain tool-response human message, ~line 7519). Its presence in an
# outbound prompt proves a tool already ran this turn. The template cannot
# interpolate this constant (its braces are format-escaped), so
# tests/unit/test_refusal_override_post_tool_guard.py pins the two copies
# byte-identical.
TOOL_RESPONSE_SCAFFOLD = 'Okay, so what is response for this tool'
def _pooled_post_with_refusal_check(api_url, json=None, app_logger=None, **kwargs):
"""Post to llama-server and apply REFUSAL_OVERRIDE to the response.
Mirrors the dispatcher-side guard at speculative_dispatcher.py:548-559
so the langchain casual_conv path gets the same refusal-detection that
the speculative dispatcher already does — closes the gap revealed by
the 2026-05-13 09:55:34 IPL turn (request 301eeed0), where the draft
refused with "I don't have the 2026 IPL table yet" and the langchain
caller had no guard, so the refusal flowed straight to the user.
Feature-flagged via HEVOLVE_LANGCHAIN_REFUSAL_OVERRIDE — default ON.
Disable explicitly only if a downstream path proves problematic:
$env:HEVOLVE_LANGCHAIN_REFUSAL_OVERRIDE = "0"
When active AND the reply matches _REFUSAL_PATTERN, retries ONCE with
a forceful tool-use directive prepended to the user message. The
directive tells the model not to refuse and to invoke a tool instead.
The retry goes back to the same api_url, so it stays on the same wire;
the only thing that changes is the prompt's tone.
"""
body = json or {}
# Fit the request to the server's per-slot n_ctx budget BEFORE the POST.
# The httpx wire hook (core.llm_outbound_logger._apply_trim_to_request)
# trims autogen / openai-SDK traffic, but this wrapper posts through the
# requests pool (core.http_pool.pooled_post), which bypasses httpx — the
# "raw requests.post minority" the trim's own architecture note flags. So
# a langchain ReAct follow-up prompt (the re-rendered full tool schema plus
# a large tool observation) reached llama-server untrimmed and 500'd with
# "request (N tokens) exceeds the available context size", after which the
# turn returned the generic error instead of an answer (live 2026-09-02,
# weather-in-London: 14,899 > 12,288 n_ctx). Route through the ONE canonical
# trimmer both wire paths share — idempotent, no-ops on bodies that already
# fit — so no second trim implementation and no budget drift. Best-effort:
# never block a chat turn on the optimisation; an untrimmed oversized body
# still fails loudly at llama-server exactly as it did before this line.
try:
from core.llm_outbound_logger import _trim_to_budget
body = _trim_to_budget(body)[0]
except Exception:
logging.getLogger(__name__).debug(
"_pooled_post_with_refusal_check: wire-trim skipped", exc_info=True)
# This is a COMPLETION, not an ordinary API call. Without this the call
# inherits http_pool.DEFAULT_TIMEOUT (3, 15) — a 15s read budget that is
# shorter than a local 4B generation, so it could never succeed: it timed
# out, the caller's `except` re-issued the whole request, and a one-word
# reply cost ~110s (see LLM_COMPLETION_TIMEOUT for the measured log trail).
# setdefault, so an explicit caller timeout still wins. Applies to the
# refusal-retry POST below too — both go through **kwargs.
kwargs.setdefault('timeout', LLM_COMPLETION_TIMEOUT)
# The configured backend's credential (core.autogen_config) rides on every
# POST that targets it; an explicit caller header still wins. A local
# llama-server has LLM_AUTH_HEADERS == {} and gets nothing.
if LLM_AUTH_HEADERS and api_url in (GPT_API, DRAFT_GPT_API):
_hdrs = dict(LLM_AUTH_HEADERS)
_hdrs.update(kwargs.get('headers') or {})
kwargs['headers'] = _hdrs
response = pooled_post(api_url, json=body, **kwargs)
# Default ON: only explicit opt-out (0/false/no/off) skips the override.
if os.environ.get('HEVOLVE_LANGCHAIN_REFUSAL_OVERRIDE', '1').strip().lower() in ('0', 'false', 'no', 'off'):
return response
try:
from integrations.agent_engine.speculative_dispatcher import _REFUSAL_PATTERN
except Exception:
return response
try:
resp_json = response.json()
reply_text = ''
if isinstance(resp_json, dict):
choices = resp_json.get('choices') or []
if choices:
_msg = choices[0].get('message') or {}
reply_text = _msg.get('content') or ''
if not reply_text:
reply_text = resp_json.get('content') or ''
except Exception:
return response
if not reply_text or not _REFUSAL_PATTERN.search(reply_text):
return response
# A refusal-shaped reply AFTER a tool observation is a truthful report,
# not a refusal to try. Live 2026-08-22 11:34 (weather-in-chennai):
# google_search returned nothing, the model said "the tool didn't return
# any weather data, I can't give you the specific forecast", the
# directive below re-prompted "Never assert data is unavailable without
# first attempting a tool call" — and the retry FABRICATED a forecast
# (sunny, 32°C) that reached the user as fact. The directive's own
# contract is "force ONE tool attempt", so it stands down when the
# outbound prompt proves an attempt already ran.
try:
_prompt_blob = ' '.join(
str(_m.get('content') or '')
for _m in (body.get('messages') or [])
if isinstance(_m, dict))
except Exception:
_prompt_blob = ''
if TOOL_RESPONSE_SCAFFOLD in _prompt_blob:
(app_logger or app.logger).info(
"[REFUSAL-OVERRIDE-LANGCHAIN] skip: prompt carries a tool "
"observation — reply is a post-tool report, not a refusal")
return response
_logger = app_logger or app.logger
_logger.info(
"[REFUSAL-OVERRIDE-LANGCHAIN] draft refusal detected: %r — retrying with forced tool-use directive",
reply_text[:160],
)
original_messages = body.get('messages', [])
if not original_messages:
return response
original_content = original_messages[0].get('content') or ''
force_prefix = (
"[SYSTEM OVERRIDE — your previous attempt was a refusal "
"(\"I don't have...\" / \"I can't access...\" / \"that's after my training\"). "
"Do NOT refuse again. You have tools available — invoke ONE of them "
"via the agent JSON action format. If unsure, default to google_search "
"or crawl4ai for live/web data. Never assert data is unavailable "
"without first attempting a tool call.]\n\n"
)
retry_body = dict(body)
retry_body['messages'] = [{
'role': original_messages[0].get('role', 'user'),
'content': force_prefix + original_content,
}]
try:
retry_response = pooled_post(api_url, json=retry_body, **kwargs)
_logger.info("[REFUSAL-OVERRIDE-LANGCHAIN] retry posted, returning new response")
return retry_response
except Exception as _e:
_logger.warning("[REFUSAL-OVERRIDE-LANGCHAIN] retry POST failed: %s; returning original refusal", _e)
return response
class CustomGPT(LLM):
casual_conv: bool
count: int = 0
previous_intent: Optional[str] = None
call_gpt4: Optional[int] = 0
total_tokens: int = 0
@property
def _llm_type(self) -> str:
return "custom"
def _call(self, prompt: str, stop: Optional[List[str]] = None) -> str:
# G12: kick off HevolveAI SSM student in parallel with llama.cpp.
# `_g12_student_future` is set for the lifetime of this call and
# drained before each return path to feed the distillation engine.
_g12_student_future = _g12_kick_off_student(prompt)
# Guard: if no LLM endpoint is configured, return gracefully instead
# of crashing with MissingSchema on every request (SRE FM3 fix).
if not (DRAFT_GPT_API or GPT_API):
app.logger.error(
'No LLM endpoint configured (GPT_API and DRAFT_GPT_API both empty). '
'Set HEVOLVE_LOCAL_LLM_URL or configure port_registry.')
_fallback_msg = "I'm not available right now — no language model endpoint is configured."
_g12_finalize(prompt, _fallback_msg, _g12_student_future)
return _fallback_msg
start_time = time.time()
self.count += 1
# self.total_tokens = 0
app.logger.info(f'calling for {self.count} times')
app.logger.info(f"len---->{len(prompt.split(' '))}")
# encoding = tiktoken.get_encoding("gpt-3.5-turbo")
num_tokens = count_tokens_for_text(prompt)
thread_local_data.update_req_token_count(num_tokens)
app.logger.info(f"len---->{num_tokens}")
# Recall-drop discriminator: prep_inputs logs "Memory loaded: N" on the
# load side; Human/AI counts show the history actually rendered here.
# N>0 with Human==1 = history dropped between memory merge and format.
app.logger.info(
f"first time calling {len(prompt)} "
f"(history blocks: Human={prompt.count('Human:')} AI={prompt.count('AI:')})")
publish_chat_stage('generating', user_id=str(thread_local_data.get_user_id() or ''), request_id=str(thread_local_data.get_request_id() or ''))
if self.count > 1 and thread_local_data.get_global_intent() != self.previous_intent:
tools = get_tools(thread_local_data.get_global_intent())
# get_tools returns Tool objects; this splice needs text, so
# render here (the only consumer that wants the string form).
tool_strings = "\n".join(
f"\n> {t.name}: {t.description}" for t in tools)
start_index = prompt.find("")
end_index = prompt.find("") + len("")
prompt = prompt[:start_index] + tool_strings + prompt[end_index:]
app.logger.info(f"second time calling {len(prompt)}")
# prompt = create_prompt(tools)
app.logger.info(prompt)
# time.sleep(10)
checker = None
# structured_llm = llm.with_structured_output(
# method="json_mode",
# include_raw=True
# )
if (self.count > 1 or self.call_gpt4 == 1):
# try:
# # app.logger.info(f"the prompt we are sending is {prompt}")
# start = time.time()
# response = pooled_post(
# GPT_API,
# json={
# "model": "gpt-4.1-mini",
# "data": [{"role": "user", "content": prompt}],
# "max_token": 2000,
# "request_id": str(thread_local_data.get_request_id())
# })
# app.logger.info(
# f"gpt 3.5 response format is {response.json()}")
# app.logger.info(
# f"gpt 3.5 response format type is {type(response.json())}")
# app.logger.info(
# " gpt 3.5 finish in {}".format(time.time()-start))
# checker = 1
# except:
# app.logger.info("gpt 3.5 fails on line number 483!!")
try:
if self.casual_conv:
app.logger.info(f"casual conv — routing to draft 0.8B")
start = time.time()
# T23: casual_conv=True routes to the 0.8B draft on
# :8081 via DRAFT_GPT_API. Falls back to GPT_API (4B)
# if the draft server isn't available.
_api = DRAFT_GPT_API or GPT_API
response = _pooled_post_with_refusal_check(
_api,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 200,
"temperature": 0.7
})
app.logger.info(
f"gpt 3.5 response format is {response.json()}")
app.logger.info(
f"gpt 3.5 response format type is {type(response.json())}")
app.logger.info(
" gpt 3.5 finish in {}".format(time.time()-start))
checker = 1
else:
app.logger.info("Non casual conv")
start = time.time()
response = _pooled_post_with_refusal_check(
GPT_API,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 200, # Reduced from 1000 for faster responses
"temperature": 0.7
})
app.logger.info(
f"gpt 3.5 response format is {response.json()}")
app.logger.info(
f"gpt 3.5 response format type is {type(response.json())}")
app.logger.info(
" gpt 3.5 finish in {}".format(time.time()-start))
checker = 1
# # `response_from_groq`.
# response_from_groq = structured_llm.invoke(prompt)
# # app.logger.info("groq response in streaming way")
# # app.logger.info("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n")
# # response_from_groq = ""
# # for chunk in llm.stream(prompt):
# # app.logger.info(f"chunk in stream {chunk}")
# # app.logger.info(f"chunk content in straming way {chunk.content}")
# # response_from_groq +=chunk.content
# # app.logger.info("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n")
# # app.logger.info(f" response from groq api {response_from_groq}")
# # app.logger.info(f" response from groq api {type(response_from_groq)}")
# app.logger.info(
# "finish in groq {}".format(time.time()-start))
# response = response_from_groq['raw'].content
# response_from_groq = response_from_groq['raw'].content
# # response = json.loads(response_from_groq.content)
# # response = json.dumps(response)
# app.logger.info(
# f" response from groq api after {response}")
# app.logger.info(
# f" response from groq api after {type(response)}")
# checker = 0
except Exception as e:
app.logger.info(f"In except the exception is {e}")
start = time.time()
response = _pooled_post_with_refusal_check(
GPT_API,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000
})
app.logger.info(
f"gpt 3.5 response format is {response.json()}")
app.logger.info(
f"gpt 3.5 response format type is {type(response.json())}")
app.logger.info("finish in {}".format(time.time()-start))
checker = 1
else:
try:
# app.logger.info(f"the prompt we are sending is {prompt}")
if self.casual_conv:
app.logger.info(
f"casual conv first call — routing to draft 0.8B")
start = time.time()
_api = DRAFT_GPT_API or GPT_API
response = _pooled_post_with_refusal_check(
_api,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000
}
)
app.logger.info(
f"gpt 3.5 response format is {response.json()}")
app.logger.info(
f"gpt 3.5 response format type is {type(response.json())}")
app.logger.info(
"gpt 3.5 finish in {}".format(time.time()-start))
checker = 1
else:
try:
app.logger.info("non casual conv")
start = time.time()
response = _pooled_post_with_refusal_check(
GPT_API,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000
}
)
app.logger.info(
f"gpt 3.5 response format is {response.json()}")
app.logger.info(
f"gpt 3.5 response format type is {type(response.json())}")
app.logger.info(
"gpt 3.5 finish in {}".format(time.time()-start))
checker = 1
# response_from_groq = structured_llm.invoke(prompt)
# app.logger.info(
# "finish in groq {}".format(time.time()-start))
# app.logger.info(
# f"this is response from groq {response_from_groq}")
# response = response_from_groq['raw'].content
# response_from_groq = response_from_groq['raw'].content
# app.logger.info(
# f" response from groq api after {response}")
# app.logger.info(
# f" response from groq api after {type(response)}")
# checker = 0
except Exception as e:
app.logger.info(f" the error is {e}")
except Exception as e:
app.logger.info(f"In except the exception is {e}")
start = time.time()
response = _pooled_post_with_refusal_check(
GPT_API,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000
}
)
app.logger.info(f"gpt 4 response format is {response.json()}")
app.logger.info(
f"gpt 4 response format type is {type(response.json())}")
app.logger.info("finish in {}".format(time.time()-start))
checker = 1
if checker == 0:
try:
app.logger.info(
f"full response that came from the gpt{response}")
text = str(response)
app.logger.info(f"text got from gpt {text}")
try:
text = text.strip('`').replace('json\n', '').strip()
except Exception:
logging.getLogger(__name__).exception("_call: swallowed Exception")
intents = json.loads(text)
app.logger.info(f"the intents are: {intents}")
curr_intent = intents["action"]
app.logger.info(f"curr_intent is: {curr_intent}")
if self.previous_intent == curr_intent:
self.call_gpt4 = 1
self.previous_intent = curr_intent
thread_local_data.update_recognize_intents(intents["action"])
except Exception as e:
app.logger.info(
f"LangChain action parse failed (non-JSON response): {e}")
# thread_local_data.update_recognize_intents("Final Answer")
# time.sleep(10)
end_time = time.time()
elapsed_time = end_time - start_time
app.logger.info(f"time taken for this call is {elapsed_time}")
num_tokens = count_tokens_for_text(
str(response).replace('\n', ' ').replace('\t', ''))
app.logger.info(f"current num_tokens: {num_tokens}")
thread_local_data.update_res_token_count(num_tokens)
end_result = str(response).replace('\n', ' ').replace('\t', '')
app.logger.info(f"the end response is {end_result}")
# G12: drain SSM student future and feed distillation engine
_g12_finalize(prompt, end_result, _g12_student_future)
return end_result
# return response_from_groq.content.replace('\n', ' ').replace('\t', '')
if checker == 1:
try:
# Extract text from OpenAI-compatible response format
text = str(response.json()["choices"][0]["message"]["content"])
try:
text = text.strip('`').replace('json\n', '').strip()
except Exception:
logging.getLogger(__name__).exception("_call: swallowed Exception")
intents = json.loads(text)
curr_intent = intents["action"]
if self.previous_intent == curr_intent:
self.call_gpt4 = 1
self.previous_intent = curr_intent
thread_local_data.update_recognize_intents(intents["action"])
except Exception as e:
app.logger.info(
f"LangChain action parse failed (non-JSON response): {e}")
# thread_local_data.update_recognize_intents("Final Answer")
# time.sleep(10)
end_time = time.time()
elapsed_time = end_time - start_time
app.logger.info(f"time taken for this call is {elapsed_time}")
resp_json = response.json()
# Guard: llama-server returns an error dict (e.g. n_ctx exceeded)
# instead of a choices array. Handle gracefully instead of crashing.
if 'error' in resp_json:
_err = resp_json['error']
_msg = _err.get('message', str(_err)) if isinstance(_err, dict) else str(_err)
app.logger.error(f"LLM server error: {_msg}")
# Do NOT put the upstream message in the reply. llama-server
# answers 200 with {"error": {"message": "Loading model"}}
# while weights load, and that reached the user verbatim as
# "I couldn't process that request - Loading model" (live
# 2026-08-18, twice on "Hi!"). It leaks internal wording,
# reads as permanent when the state is transient, and is
# returned as a normal assistant turn, so it landed in
# conversation history and was replayed to the model as a
# prior turn. The detail is already in the log line above.
from core.constants import (
LLM_GENERIC_ERROR_REPLY,
LLM_LOADING_REPLY,
LLM_TRANSIENT_LOADING_MARKERS,
)
_low = (_msg or '').strip().lower()
if any(_m in _low for _m in LLM_TRANSIENT_LOADING_MARKERS):
# Same wording routes/chatbot_routes.py sends when the
# server is unreachable, so "down" and "warming up" read
# identically to the user.
_err_reply = LLM_LOADING_REPLY
else:
_err_reply = LLM_GENERIC_ERROR_REPLY
# G12: drain SSM student future and feed distillation engine
_g12_finalize(prompt, _err_reply, _g12_student_future)
return _err_reply
response_text = resp_json["choices"][0]["message"]["content"]
num_tokens = count_tokens_for_text(
response_text.replace('\n', ' ').replace('\t', ''))
thread_local_data.update_res_token_count(num_tokens)
_final_text = response_text.replace('\n', ' ').replace('\t', '')
# G12: drain SSM student future and feed distillation engine
_g12_finalize(prompt, _final_text, _g12_student_future)
return _final_text
@property
def _identifying_params(self) -> Mapping[str, Any]:
"""Get the identifying parameters."""
return {
}
# llm = CustomGPT(casual_conv=True)
class CustomAgentExecutor(AgentExecutor):
def prep_outputs(self, inputs: Dict[str, str],
outputs: Dict[str, str],
return_only_outputs: bool = False) -> Dict[str, str]:
self._validate_outputs(outputs)
req_id = thread_local_data.get_request_id()
prom_id = thread_local_data.get_prompt_id()
metadata = {'request_Id': req_id, 'prompt_id': prom_id}
app.logger.info(
f"before: memory object is not none and metadata is {metadata}, {return_only_outputs}")
if self.memory is not None:
app.logger.info(
f"memory object is not none and metadata is {metadata}")
try:
self.memory.save_context(inputs, outputs, metadata=metadata)
# Force immediate flush so next request sees the buffer on disk
if hasattr(self.memory, 'chat_memory') and hasattr(self.memory.chat_memory, 'flush_sync'):
self.memory.chat_memory.flush_sync()
app.logger.info(
f"After: memory saved successfully with metadata {metadata}, "
f"buffer_size={len(self.memory.chat_memory.messages) if hasattr(self.memory, 'chat_memory') else '?'}")
except Exception as e:
app.logger.error(f"Failed to save memory: {e}")
# Continue without crashing - memory save is not critical
# Register conversation turn in MemoryGraph (fire-and-forget, no latency)
try:
user_id = thread_local_data.get_user_id()
graph = _get_or_create_graph(user_id, prom_id)
if graph:
user_input = inputs.get('input', '')
ai_output = outputs.get('output', '')
session_key = f"{user_id}_{prom_id}" if prom_id else str(user_id)
def _bg_register(g=graph, ui=user_input, ao=ai_output, sk=session_key):
try:
g.register_conversation('user', ui, sk)
g.register_conversation('langchain', ao, sk)
except Exception:
logging.getLogger(__name__).exception("_bg_register: swallowed Exception")
threading.Thread(target=_bg_register, daemon=True).start()
# Stamp the request so _chat_reply's gap-filler (which
# indexes early-return turns, #729) does not register
# this same turn a second time.
try:
from flask import g as _flask_g, has_request_context as _hrc
if _hrc():
_flask_g._hie_graph_indexed = True
except Exception:
pass
except Exception:
logging.getLogger(__name__).exception("prep_outputs: swallowed Exception") # Non-blocking
else:
app.logger.info(
f"Memory object is None, skipping save")
if return_only_outputs:
return outputs
else:
return {**inputs, **outputs}
def prep_inputs(self, inputs: Union[Dict[str, Any], Any]) -> Dict[str, str]:
"""Validate and prepare chain inputs, including adding inputs from memory.
Args:
inputs: Dictionary of raw inputs, or single input if chain expects
only one param. Should contain all inputs specified in
`Chain.input_keys` except for inputs that will be set by the chain's
memory.
Returns:
A dictionary of all inputs, including those added by the chain's memory.
"""
if not isinstance(inputs, dict):
_input_keys = set(self.input_keys)
if self.memory is not None:
# If there are multiple input keys, but some get set by memory so that
# only one is not set, we can still figure out which key it is.
_input_keys = _input_keys.difference(
self.memory.memory_variables)
if len(_input_keys) != 1:
raise ValueError(
f"A single string input was passed in, but this chain expects "
f"multiple inputs ({_input_keys}). When a chain expects "
f"multiple inputs, please call it by passing in a dictionary, "
"eg `chain({'foo': 1, 'bar': 2})`"
)
inputs = {list(_input_keys)[0]: inputs}
if self.memory is not None:
try:
external_context = self.memory.load_memory_variables(inputs)
chat_hist = external_context.get('chat_history', [])
app.logger.info(
f"Memory loaded: {len(chat_hist)} messages in chat_history"
f" (buffer_file={getattr(getattr(self.memory, 'chat_memory', None), '_buffer_file', '?')})")
inputs = dict(inputs, **external_context)
except Exception as e:
app.logger.warning(f"Could not load memory: {e}")
inputs['chat_history'] = []
# time.sleep(4)
self._validate_inputs(inputs)
return inputs
# helper functions
def get_memory(user_id: int):
'''
Get memory object — SimpleMem-backed (local, zero-latency reads)
'''
from integrations.channels.memory.simplemem_langchain import SimpleMemChatMemory
return SimpleMemChatMemory.load_or_create(user_id)
def get_action_user_details(user_id):
"""Thin delegate to the canonical user-context resolver.
The full action-history + profile fetch lives in
``core.user_context.get_user_context`` which layers:
1. 30-second per-user TTL cache — subsequent /chat messages
within the window reuse the last fetch in microseconds.
2. 1.5-second total hot-path budget — a stalled backend can't
block chat more than 1.5s; background refresh populates the
cache for the next request.
3. Unified format — the same rich output that was previously
duplicated across hart_intelligence_entry, create_recipe,
and reuse_recipe with subtle drift.
There is no message-content classifier here. Chat intent is
classified by the draft 0.8B model in dispatch_draft_first with
the augmented classifier prompt; callers that want to skip this
fetch for a casual message should consult that envelope upstream,
not re-classify in Python.
This shim is retained (rather than removing the symbol) so any
dynamic caller via ``getattr(hart_intelligence_entry, 'get_action_user_details')``
keeps working. New code should import ``get_user_context`` directly.
"""
from core.user_context import get_user_context
return get_user_context(user_id=user_id, mode='reuse')
def get_time_based_history(prompt: str, session_id: str, start_date: str, end_date: str):
'''Time-filtered + semantic conversation history retrieval.
#121: the date-recall implementation is now SINGLE-SOURCED in
helper.get_time_based_history (ConversationEntry BETWEEN-range + SimpleMem
semantic fallback). Both the langchain FULL_HISTORY path (parsing_string ->
here) and the autogen get_chat_history tool (core/agent_tools.py ->
helper.get_time_based_history) now use that one impl, instead of the prior
fork (this working copy vs helper's dead-Zep twin). helper does not import
this module, so the delegate is a one-way dependency (no import cycle).
'''
from hartos import helper
return helper.get_time_based_history(prompt, session_id, start_date, end_date)
def parsing_string(string):
'''
this function will extract infromation for above function ie prompt start date end date
'''
try:
app.logger.info(" The string to parse: {string}")
prompt, start_date, end_date = [s.strip() for s in string.split(",")]
session_id = 'user_'+str(thread_local_data.get_user_id())
return get_time_based_history(prompt, session_id, start_date, end_date)
except Exception:
now = datetime.utcnow()
formatted_time = now.strftime('%Y-%m-%dT%H:%M:%S.%f') + 'Z'
session_id = "user_"+str(thread_local_data.get_user_id())
return get_time_based_history(string, session_id, formatted_time, formatted_time)
def parse_date(date_str):
return datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S")
def parse_character_animation(string):
'''
Dreambooth character animation api
input string
how this function works
1 get user information based on user_id
2 get fav teacher
3 call dreambooth api with fav teacher name
'''
try:
post_dict = {'user_id': '', 'task_type': 'async', 'status': TaskStatus.EXECUTING.value, 'task_name': TaskNames.ANIMATE_CHARACTER.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.ANIMATE_CHARACTER.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id()}
publish_async('com.hertzai.longrunning.log', post_dict)
prompt = string
student_id_url = STUDENT_API
payload = json.dumps({
"user_id": thread_local_data.get_user_id()
})
headers = {
'Content-Type': 'application/json'
}
response = requests.request(
"POST", student_id_url, headers=headers, data=payload)
if response.status_code == 200:
favorite_teacher_id = response.json()["favorite_teacher_id"]
get_image_by_id_url = f"{FAV_TEACHER_API}/{favorite_teacher_id}"
payload = {}
headers = {}
response = requests.request(
"GET", get_image_by_id_url, headers=headers, data=payload)
image_name = response.json()["image_name"]
image_url = response.json()["image_url"]
image_response = pooled_get(image_url)
image_content = image_response.content
image_name = image_name.replace("vtoonify_", "", 1)
folder_name = image_name.split(".")[0]
inference_url = f"{DREAMBOOTH_API}/generate_images"
payload = {'prompt': prompt}
headers = {}
logging.info("done till here")
files = [
# Use the correct content type
('image', ('image.jpeg', image_content, 'image/jpeg'))
]
url = "http://20.197.30.74:8000/generate_image/"
response = pooled_post(url, headers=headers,
data=payload, files=files)
if response.status_code == 200:
return response.json()["url"]
else:
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.ERROR.value, 'task_name': TaskNames.ANIMATE_CHARACTER.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.ANIMATE_CHARACTER.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at dreamooth api end for re {thread_local_data.get_request_id()}'}
publish_async('com.hertzai.longrunning.log', post_dict)
except Exception as e:
# logging.info(f"exception {e}")
time.sleep(30)
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.TIMEOUT.value, 'task_name': TaskNames.ANIMATE_CHARACTER.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.ANIMATE_CHARACTER.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at dreamooth api end for req_id {thread_local_data.get_request_id()} timed out'}
publish_async('com.hertzai.longrunning.log', post_dict)
return "something went wrong"
def parse_text_to_image(inp):
'''
stable diffusion
'''
try:
post_dict = {'user_id': '', 'task_type': 'async', 'status': TaskStatus.EXECUTING.value, 'task_name': TaskNames.STABLE_DIFF.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.STABLE_DIFF.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id()}
publish_async('com.hertzai.longrunning.log', post_dict)
url = f'{STABLE_DIFF_API}?prompt={inp}'
payload = {}
headers = {}
response = requests.request(
"POST", url, headers=headers, data=payload, timeout=240)
if response.status_code == 200:
return response.json()["img_url"]
else:
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.ERROR.value, 'task_name': TaskNames.STABLE_DIFF.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.STABLE_DIFF.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at stable diff for req_id: {thread_local_data.get_request_id()}'}
publish_async('com.hertzai.longrunning.log', post_dict)
except Exception as e:
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.TIMEOUT.value, 'task_name': TaskNames.ANIMATE_CHARACTER.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.ANIMATE_CHARACTER.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at stable diff for req_id: {thread_local_data.get_request_id()} timed out'}
publish_async('com.hertzai.longrunning.log', post_dict)
return f"{e} Not able to generating image at this moment please try later"
def parse_image_to_text(inp):
'''
LlaVA implemetation
'''
# Saved attachments use this node's canonical image describer. Remote
# URL behavior remains below; a local reference never goes to LLAVA.
image_ref, _, question = str(inp).partition(',')
image_ref = image_ref.strip()
if image_ref.startswith('/uploads/'):
from integrations.vision.image_describe import resolve_uploaded_image, describe_image
try:
path = resolve_uploaded_image(image_ref)
answer = describe_image(str(path), question.strip() or None, cache=True)
return answer or 'Uploaded image analysis is unavailable; no visual answer was obtained.'
except (ValueError, FileNotFoundError) as e:
return f'Uploaded image analysis failed: {e}'
try:
post_dict = {'user_id': '', 'task_type': 'async', 'status': TaskStatus.EXECUTING.value, 'task_name': TaskNames.LLAVA.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.LLAVA.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id()}
publish_async('com.hertzai.longrunning.log', post_dict)
inp_list = inp.split(',')
url = f'{LLAVA_API}'
payload = {
'url': inp_list[0],
'prompt': inp_list[1]
}
files = []
headers = {}
response = requests.request(
"POST", url, headers=headers, data=payload, files=files, timeout=300)
if response.status_code == 200:
return response.text
else:
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.ERROR.value, 'task_name': TaskNames.LLAVA.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.LLAVA.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at LLAVA for req_id: {thread_local_data.get_request_id()}'}
publish_async('com.hertzai.longrunning.log', post_dict)
except Exception as e:
post_dict = {'user_id': thread_local_data.get_user_id(), 'status': TaskStatus.TIMEOUT.value, 'task_name': TaskNames.LLAVA.value, 'uid': thread_local_data.get_request_id(
), 'task_id': f"{TaskNames.LLAVA.value}_{str(thread_local_data.get_request_id())}", 'request_id': thread_local_data.get_request_id(), 'failure_reason': f'Exception happend at LLAVA for req_id: {thread_local_data.get_request_id()} timed out'}
publish_async('com.hertzai.longrunning.log', post_dict)
return f'{e} Not able to generating answer at this moment please try later'
def parse_link_for_crwalab(inp):
"""Extract content from a URL (PDF or website).
The agent sees every intermediate step (progress log) and the final
extracted content directly — no opaque HTTP calls to external services.
"""
inp_list = inp.split(',')
input_url = inp_list[0].strip()
link_type = inp_list[1].strip() if len(inp_list) > 1 else 'website'
user_id = thread_local_data.get_user_id()
request_id = thread_local_data.get_request_id()
app.logger.info(f"Data extraction: url={input_url}, type={link_type}")
# Publish task status so longrunning monitor knows
post_dict = {
'user_id': user_id, 'task_type': 'sync',
'status': TaskStatus.EXECUTING.value,
'task_name': TaskNames.CRAWLAB.value,
'uid': request_id,
'task_id': f"{TaskNames.CRAWLAB.value}_{request_id}",
'request_id': request_id,
}
publish_async('com.hertzai.longrunning.log', post_dict)
def _publish_thinking(msg):
"""Push progress to UI via Crossbar thinking bubble."""
from core.peer_link.crossbar_publish import publish_thinking_trace
publish_thinking_trace(
text=msg, user_id=user_id, request_id=request_id,
bot_type='Agent',
)
try:
if link_type == 'pdf':
return _parse_pdf_in_process(input_url, user_id, request_id)
else:
# Website: crawl in-process, agent + UI see progress
_publish_thinking(f"Crawling {input_url}...")
from integrations.web_crawler import crawl_url
result = crawl_url(input_url, timeout=30)
if result['success']:
_publish_thinking(f"Extracted {result['word_count']} words from {input_url}")
content = result['markdown']
if len(content) > 8000:
truncate_pos = content.rfind('.', 0, 8000)
if truncate_pos > 6000:
content = content[:truncate_pos + 1] + "\n[Content truncated]"
else:
content = content[:8000] + "\n[Content truncated]"
parts = []
if result.get('progress'):
parts.append("--- Progress ---")
parts.append(result['progress'])
parts.append("--- Result ---")
parts.append(f"URL: {input_url}")
parts.append(f"Words extracted: {result['word_count']}")
parts.append(f"Content:\n{content}")
return "\n".join(parts)
else:
_publish_thinking(f"Crawl failed: {result.get('error', 'unknown')}")
return f"Failed to crawl {input_url}: {result.get('error', 'unknown')}"
except Exception as e:
app.logger.error(f"Data extraction failed: {e}")
post_dict['status'] = TaskStatus.ERROR.value
post_dict['failure_reason'] = str(e)
publish_async('com.hertzai.longrunning.log', post_dict)
return f"Failed to extract content from {input_url}: {e}"
def _parse_pdf_in_process(input_url, user_id, request_id):
"""Read a PDF the agent found by URL: download it, then hand it to the ONE
book pipeline (integrations/learning/book_pipeline.py).
This used to re-run the whole parse sequence inline, against helpers
imported from Nunba's routes.upload_routes: a second orchestrator of the
same pipeline, and one a HARTOS node without Nunba could never run (it fell
through to a hive-peer offload, then a cloud URL). The pipeline now runs on
every node, so this only downloads, delegates, and formats what the agent
sees. Progress still reaches the UI on com.hertzai.bookparsing.{user_id}:
the pipeline publishes it, with the book's real file_id.
"""
from integrations.learning import book_pipeline
progress = [f"Downloading PDF from {input_url}..."]
# A failed download raises to the caller, which reports the task as failed,
# as it always has; a PDF that cannot be read is answered here.
pdf_path = book_pipeline.fetch_pdf(input_url)
progress.append(f"PDF saved: {pdf_path.stat().st_size} bytes")
try:
result = book_pipeline.parse_book(pdf_path, user_id, request_id, log=progress)
except book_pipeline.BookParseError as e:
return "\n".join(progress + [f"Error: {e}"])
if result.get('stored') is False:
# Read, but the library could not keep it: the agent still gets the
# text, as this reader always did.
progress.append(f"Not saved to the book library: {result.get('store_error')}")
whole_text = result['whole_text']
content_for_agent = whole_text
if len(content_for_agent) > 8000:
truncate_pos = content_for_agent.rfind('.', 0, 8000)
if truncate_pos > 6000:
content_for_agent = content_for_agent[:truncate_pos + 1] + "\n[Content truncated]"
else:
content_for_agent = content_for_agent[:8000] + "\n[Content truncated]"
return (
f"--- PDF Parse Progress ---\n"
f"{chr(10).join(progress)}\n"
f"--- Extracted Content ---\n"
f"File: {pdf_path.name}\n"
f"Book: {result.get('book_name') or 'Unknown'}\n"
f"Pages: {result['total_pages']}\n"
f"Total words: {len(whole_text.split())}\n"
f"Chapters: {result['chapters']}\n"
f"---\n{content_for_agent}"
)
try:
redis_client = redis.StrictRedis(
host='azure_all_vms.hertzai.com', port=6369, db=0,
socket_connect_timeout=2, socket_timeout=2)
redis_client.ping()
except Exception:
redis_client = None
def get_frame(user_id):
"""Get latest camera frame — FrameStore first, screenshot fallback, Redis last."""
# Primary: FrameStore (in-process, zero latency). Go through
# get_frame_store() rather than get_vision_service().store so
# every frame-store access funnels through a single accessor —
# if tomorrow we move the store behind a proxy/cache/adapter, we
# change one helper, not three reach-ins.
fs = get_frame_store()
if fs is not None:
frame_bytes = fs.get_frame(str(user_id))
if frame_bytes is not None:
import cv2
frame = cv2.imdecode(
np.frombuffer(frame_bytes, np.uint8), cv2.IMREAD_COLOR,
)
if frame is not None:
app.logger.info(
f"Frame for user_id {user_id} from FrameStore")
return frame[:, :, ::-1] # BGR → RGB
# Desktop screenshot fallback (for computer use mode), behind the same
# gate _handle_screenshot_tool reads (core.ai_sensing.allowed: the eye
# button's cut, or the owner's No to the screen). Ungated, a No to both
# feeds emptied the FrameStore and this fallback then handed
# Visual_Context_Camera the desktop the owner had just refused (review
# of 55a6cde43 / 00fc7a37b, measured).
from core.ai_sensing import allowed
if allowed('screen'):
try:
from PIL import ImageGrab
screenshot = ImageGrab.grab()
frame = np.array(screenshot)
app.logger.info(f"Frame for user_id {user_id} from desktop screenshot ({frame.shape})")
return frame # Already RGB
except Exception as _ss_err:
app.logger.debug(f"Screenshot fallback failed: {_ss_err}")
else:
app.logger.info(f"No screenshot for user_id {user_id}: the screen sense is off")
# Last resort: Redis (legacy camera path)
if redis_client is None:
app.logger.info(f"No frame for user_id {user_id} — Redis unavailable, no camera/screenshot.")
return None
serialized_frame = redis_client.get(user_id)
try:
if serialized_frame is not None:
from security.safe_deserialize import safe_load_frame
frame_bgr = safe_load_frame(serialized_frame)
app.logger.info(
f"Frame for user_id {user_id} from Redis")
frame = frame_bgr[:, :, ::-1]
return frame
else:
app.logger.info(f"No frame found for user_id {user_id}.")
return None
except ModuleNotFoundError as e:
app.logger.info("ModuleNotFoundError: %s", "Numpy errr", exc_info=True)
app.logger.info("Numpy version: %s", np.__version__)
app.logger.info("Numpy location: %s", np.__file__)
raise e
def parse_visual_context(inp: str):
user_id = thread_local_data.get_user_id()
request_id = thread_local_data.get_request_id()
app.logger.info('Using Vision to answer question')
frame = get_frame(str(user_id))
# No frame yet? The VisionService might simply be off. Start it once,
# give frames a moment to arrive, retry. If still nothing the tool
# returns an honest message instead of pretending there was no
# visual context. Single start/stop path — reuses the same
# get_vision_service() singleton the admin toggle and approval
# handler use, so we never end up with two VisionService instances
# fighting over the WebSocket port.
if frame is None:
try:
from integrations.vision import get_vision_service
vs = get_vision_service()
if not vs.is_running():
app.logger.info(
'parse_visual_context: VisionService idle — starting for tool call'
)
vs.start(mode='full')
# Brief poll for the first frame to land in FrameStore.
# Description loop runs every ~4s so we give it a short
# window before giving up.
import time as _t
for _ in range(20): # 20 × 0.25s = 5s total
_t.sleep(0.25)
frame = get_frame(str(user_id))
if frame is not None:
break
except ImportError:
logging.getLogger(__name__).debug("parse_visual_context: swallowed ImportError")
except Exception as _e:
app.logger.debug(f'parse_visual_context auto-start failed: {_e}')
if frame is None:
# Still nothing — tell the caller the truth instead of silently
# returning None, which the LangChain agent would interpret as
# "no visual info" and confabulate an answer from memory.
return (
"No camera frames available yet. I just started the vision "
"service — give it a couple of seconds and ask again, or "
"check that your camera is on."
)
if frame is not None:
image_path = f"output_images/{user_id}_{request_id}_call.jpg"
# Ensure the directory exists
directory = os.path.dirname(image_path)
if not os.path.exists(directory):
os.makedirs(directory)
# Convert the frame (which is a NumPy array) to a PIL image
image = Image.fromarray(frame)
# Save the image
image.save(image_path)
prompt_text = f'Instruction: Respond in second person point of view\ninput:-{inp}'
# Tier 0: the configured LLM (locally Qwen+mmproj on the llama-server
# that is already running, zero extra VRAM)
try:
import base64 as _b64
with open(image_path, 'rb') as _imgf:
_img_b64 = _b64.b64encode(_imgf.read()).decode('ascii')
_vlm_resp = requests.post(
GPT_API,
json={
'model': LLM_MODEL_NAME,
'messages': [{'role': 'user', 'content': [
{'type': 'text', 'text': prompt_text},
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{_img_b64}'}}
]}],
'max_tokens': 300,
},
headers=LLM_AUTH_HEADERS,
timeout=15,
)
if _vlm_resp.status_code == 200:
_vlm_data = _vlm_resp.json()
result = _vlm_data.get('choices', [{}])[0].get('message', {}).get('content', '')
if result:
app.logger.info(f'Visual context from Qwen+mmproj: {result[:100]}')
return result
except Exception as e:
app.logger.debug(f"Qwen+mmproj VLM unavailable: {e}")
# Tier 1: Try local MiniCPM sidecar (port 9891) — dedicated VLM server
local_minicpm_port = int(os.environ.get('HEVOLVE_MINICPM_PORT', 9891))
try:
with open(image_path, 'rb') as f:
r = requests.post(
f'http://localhost:{local_minicpm_port}/describe',
data=f.read(),
params={'prompt': prompt_text},
headers={'Content-Type': 'application/octet-stream'},
timeout=10,
)
if r.status_code == 200:
result = r.json().get('result', r.text)
app.logger.info(f'Visual context from local MiniCPM: {result[:100]}')
return result
except Exception as e:
app.logger.debug(f"Local MiniCPM sidecar unavailable, falling back: {e}")
# Tier 2: Hive mesh peer with GPU
try:
from integrations.agent_engine.compute_mesh_service import get_compute_mesh
mesh = get_compute_mesh()
# user_id: the person this peer's compute is charged to
# (ComputeMeshService._charged).
result = mesh.offload_to_best_peer(
model_type='vision', prompt=prompt_text,
options={'image_path': image_path, 'timeout': 60,
'user_id': str(user_id or '')},
)
if result and 'error' not in result:
return result.get('response', str(result))
except Exception as e:
app.logger.debug("Hive mesh vision offload not available: %s", e)
# Tier 3: Cloud MiniCPM fallback — gated through the canonical
# ConsentService so cloud egress always has a user-grant trail.
# Resolution order:
# (a) HEVOLVE_VISION_CLOUD_FALLBACK=true env var → blanket
# deploy-time grant (cloud/regional tiers where cloud IS
# the local — see scripts/start_cloud.sh + start_regional.sh)
# (b) get_vision_api() returns a non-default URL → user
# configured a custom endpoint, that's their explicit opt-in
# (c) Otherwise: ConsentService.auto_grant_with_notice(...)
# creates a granted record on first use and emits
# consent.auto_granted, so the user is told cloud was
# used and can revoke in one tap. A prior explicit
# revoke returns False and we refuse rather than
# silently re-grant. Never silently uploads.
# There is NO request-then-wait branch here BY DESIGN: this hot
# path must answer the turn, so it auto-grants-with-notice
# rather than ask-and-block. (The old rationale -- that
# request_consent had zero callers and emitted nothing -- is
# obsolete as of 2026-08-26: the daemon-goal gate and the
# screen producer both file asks through it, and it now _emits
# consent.request like its siblings. The design choice above
# is what keeps this branch auto-grant, not that gap.)
from core.config_cache import get_vision_api
_user_vision_url = (get_vision_api() or '').strip()
_allow_cloud = (
os.environ.get('HEVOLVE_VISION_CLOUD_FALLBACK', '').strip().lower()
in ('1', 'true', 'yes', 'on')
)
if not _user_vision_url and not _allow_cloud:
# Privacy-aware auto-grant: never block in the name of
# privacy. ConsentService.auto_grant_with_notice silently
# creates a granted record on first use AND emits a
# 'consent.auto_granted' notification so the user knows
# cloud was used and can revoke with one tap. Honors prior
# explicit revoke — if the user previously revoked, returns
# False and we refuse rather than silently re-grant.
_consent_ok = True
try:
from integrations.social.consent_service import ConsentService
from integrations.social.models import db_session
with db_session(commit=True) as _consent_db:
_consent_ok = ConsentService.auto_grant_with_notice(
_consent_db, str(user_id), 'cloud_egress',
scope='vision',
reason=(
"Local Qwen3-VL isn't running on this device; "
"your camera frame was sent to the cloud "
"vision endpoint to answer your question. "
"Tap to revoke if you'd rather wait for the "
"local model to come up."
),
)
except Exception as _consent_err:
# Consent system unavailable — fail CLOSED for CLOUD EGRESS of the
# user's screen: never send the image to the cloud without a verifiable
# grant (humans-always-in-control). This is egress of SENSITIVE data, so
# the "nothing fails in name of privacy" principle — which protects LOCAL
# UX from privacy theatre — does NOT apply here; the on-device model path
# stays available. Audit-logged so the refusal is not silent.
app.logger.warning(
"Visual QA: consent system unavailable (%s) — refusing cloud "
"fallback (fail-closed; on-device path unaffected).", _consent_err)
_consent_ok = False
if not _consent_ok:
# ONLY path that refuses: user explicitly revoked the
# cloud_egress[vision] consent earlier. Re-grant
# requires a fresh user action (settings UI).
return ("Vision cloud fallback was previously turned off "
"in your settings; re-enable it under Privacy → "
"Cloud Egress to use vision when local isn't "
"available.")
url = _user_vision_url or "http://azurekong.hertzai.com:8000/minicpm/upload"
payload = {'prompt': prompt_text}
fh = open(image_path, 'rb')
try:
files = [
('file', ('call.jpg', fh, 'image/jpeg'))
]
response = pooled_post(url, headers={}, data=payload, files=files, timeout=30)
app.logger.info(response.text)
return response.text
except Exception as e:
app.logger.error('Got error in visual QA (cloud fallback): %s', e)
finally:
fh.close()
return "No visual context available — camera not active."
# Shared by BOTH get_tools branches (is_first=True labeled_tool and the
# is_first=False exhaustive list) — same single-source shape as
# _CREATE_AGENT_TOOL_DESCRIPTION. Live 2026-08-24 07:01: the tool was
# registered only in the is_first=True branch, so every casual_conv=False
# turn (the branch enumeration questions actually reach since 15ed5874)
# got a registry WITHOUT it and the model truthfully denied having it.
_LIST_AGENTS_TOOL_DESCRIPTION = (
"List every agent available here: built-in expert agents, the user's "
"own created agents, and hive agents shared from peer nodes or pulled "
"from central (marked origin='hive'), with name, category, and "
"description. Use whenever the user asks what agents exist, what "
"agents they have, or which agent could handle a task — never say "
"there are no separate agents without calling this. Input: an optional "
"search term to filter by name or skill, or an empty string for the "
"full list.")
def _parse_list_agents(inp: str = ''):
"""Chat-tool wrapper over the canonical agent enumeration.
Delegates to integrations.mcp._tool_impls.list_agents — the same
implementation the MCP bridge serves — so there is ONE enumeration.
Before this wrapper the chat model answered "I do not have separate
agents" while 4 expert + 2,633 dynamic recipes sat in the registry
(live 2026-08-23 15:02, #688 P3)."""
from integrations.mcp._tool_impls import list_agents
q = (inp or '').strip()
return list_agents(query=q or None)
def parse_user_id(inp: str):
url = 'https://azurekong.hertzai.com:8443/db/getstudent_by_user_id'
headers = {
'Content-Type': 'application/json'
}
try:
prov_user_id = re.findall(r'\d', inp)[0]
except Exception:
prov_user_id = ""
payload = json.dumps({
"user_id": thread_local_data.get_user_id()
})
response = requests.request("POST", url, headers=headers, data=payload)
if prov_user_id == "" or int(prov_user_id) != thread_local_data.get_user_id():
return f"you might interested in finding your user detail here are details {response.text}"
else:
return response.text
async def fetch(session, url):
try:
async with session.get(url) as response:
start_time = time.time()
html = await response.text()
soup = BeautifulSoup(html, 'html.parser')
end_time = time.time()
elapsed_time = end_time - start_time
app.logger.info(f"time taken to crawl {url} is {elapsed_time}")
return soup.get_text()
except Exception as e:
app.logger.error(f"An error occurred while fetching {url}: {e}")
return ""
async def async_main(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
def top5_results(query):
"""Return the top-5 web results for ``query`` — None-safe + exception-safe.
The module-level ``search`` (GoogleSearchAPIWrapper) is allowed to be
None when GOOGLE_API_KEY / GOOGLE_CSE_ID env vars are missing (see
line ~2033 where the constructor is wrapped in try/except). Earlier
versions of this function called ``search.results(...)`` unguarded,
so any local-only install reproduced the trace:
AttributeError: 'NoneType' object has no attribute 'results'
File "hart_intelligence_entry.py", line 6597, in top5_results
(Last logged: 2026-06-07 15:55:59 — `google_search('weather in
chennai right now')`.) Fix: short-circuit when ``search`` is None,
catch any exception from ``search.results()`` itself, and broaden
the existing ``except RuntimeError`` to ``except Exception`` so a
transient network failure doesn't kill the calling agent either.
"""
final_res = []
cleaned_text = ""
if search is None:
app.logger.warning(
"top5_results: GoogleSearchAPIWrapper unavailable "
"(missing GOOGLE_API_KEY / GOOGLE_CSE_ID).")
return ("Web search is not configured on this machine "
"(GOOGLE_API_KEY / GOOGLE_CSE_ID are not set), so no results "
"could be fetched. This is a setup gap, not an empty web.")
try:
top_2_search_res = search.results(query, 2) or []
except Exception as e:
app.logger.warning(f"top5_results: search.results() failed: {e}")
return (f"The web search call failed and returned no results: {e}. "
f"Treat this as a failed step, not as 'nothing was found'.")
top_2_search_res_link = [
res['link'] for res in top_2_search_res
if isinstance(res, dict) and 'link' in res
]
if not top_2_search_res_link:
# THE EXIT THAT ACTUALLY FIRES, and it used to be silent. Measured
# 2026-09-07: 79 of 158 google_search payloads reaching StatusVerifier
# were the two characters '[]', while the two logged exits above
# produced ZERO warnings across 47 live invocations -- so every one of
# those empties came through here, with no diagnostic at all.
#
# The raw payload is logged because the CAUSE lives in it: the CSE can
# answer 200-OK with an entry that carries no 'link' (e.g. langchain's
# "No good Google Search Result was found" sentinel), which is a very
# different thing from a quota error, and neither is distinguishable
# once this returns a bare [].
app.logger.warning(
"top5_results: search.results() returned %d entr(y/ies) but none "
"carried a 'link' -- returning no-results. raw=%.400r",
len(top_2_search_res), top_2_search_res)
return ("The web search ran but returned no usable results for this "
"query (no result carried a link). Do NOT cite sources for "
"this step -- none were retrieved.")
try:
text = asyncio.run(async_main(top_2_search_res_link))
# Removing punctuation and extra characters
app.logger.info(text)
if text and len(text) >= 2:
cleaned_text = re.sub(r'[^\w\s]', '', text[0] +
" " + text[1]) # Remove punctuation
# Remove extra newlines and leading/trailing whitespaces
cleaned_text = re.sub(r'\n+', '\n', cleaned_text).strip()
except Exception as e:
app.logger.error(f"top5_results: fetch/clean failed: {e}")
final_res.append({'text': cleaned_text, 'source': top_2_search_res_link})
app.logger.info(f"res:-->{final_res}")
return final_res
class CustomConvoOutputParser(AgentOutputParser):
"""Output parser for the conversational agent."""
def get_format_instructions(self) -> str:
return FORMAT_INSTRUCTIONS
def parse(self, text: str) -> Union[AgentAction, AgentFinish]:
try:
response = parse_json_markdown(text)
action, action_input = response["action"], response["action_input"]
if action == "Final Answer" or action == "Final_Answer":
return AgentFinish({"output": action_input}, text)
else:
return AgentAction(action, action_input, text)
except Exception as e:
# str = ""
app.logger.info(text)
app.logger.info(f"Caught Exception while parsing output {e}")
pattern = r"final\s*[_]*answer"
try:
if re.search(pattern, text, re.IGNORECASE):
# Extract the JSON part from the string
escape_chars = ['\n', '\t', '\r',
'\"', "\'", '\\', "'''", '"""']
start_index = text.index('{')
try:
end_index = text.rindex('}') + 1
except Exception:
text += '"}'
end_index = text.rindex('}') + 1
json_string = text[start_index:end_index]
try:
parsed_json = parse_json_markdown(json_string)
except Exception as e:
parsed_json = parse_json_markdown(json_string.replace('\n', '').replace('\t', '').replace('\r', '').replace(
'\"', '').replace("\'", '').replace('\\', '').replace("'''", '').replace('"""', '').replace('`', ''))
action_input = parsed_json["action_input"]
return AgentFinish({"output": action_input}, text)
else:
app.logger.info(text)
start_index = text.index('{')
try:
end_index = text.rindex('}') + 1
except Exception:
text += '"}'
end_index = text.rindex('}') + 1
try:
json_string = text[start_index:end_index]
response = parse_json_markdown(json_string)
action, action_input = response["action"], response["action_input"]
except Exception as innerException:
app.logger.info(
"Caught inner Exception: {innerException}")
return AgentFinish({"output": text}, text)
return AgentAction(action, action_input, text)
# raise OutputParserException(f"Could not parse LLM output: {text}") from e
except Exception as e:
app.logger.info(f"Encounter an except {e} for ai msg")
# Check for the 'AI:' pattern in the response
ai_pattern = r"AI: (.+)"
ai_match = re.search(ai_pattern, text, re.IGNORECASE)
if ai_match:
final_answer = ai_match.group(1).strip()
return AgentFinish({"output": final_answer}, text)
# Fallback: treat entire response as final answer
app.logger.info("No parsable format found, using raw text as final answer")
return AgentFinish({"output": text.strip()}, text)
@property
def _type(self) -> str:
return "conversational_chat"
# Store user-specific agents and their chat history
# Only initialize if autogen is available
if autogen is not None:
user_agents_creator: Dict[str, Tuple[autogen.AssistantAgent,
autogen.UserProxyAgent]] = {}
# Store agents for each user
user_agents: Dict[str, Tuple[autogen.ConversableAgent,
autogen.ConversableAgent]] = {}
else:
user_agents_creator: Dict[str, Tuple] = {}
user_agents: Dict[str, Tuple] = {}
# Define autogen-dependent functions only if autogen is available
if autogen is not None:
def create_agents_for_user(user_id: str) -> Tuple[autogen.AssistantAgent, autogen.UserProxyAgent]:
"""Create new assistant and user proxy agents for a user with basic configuration."""
# Use the dynamic module-level config_list (cloud or local, set by wizard)
from hartos.threadlocal import thread_local_data as _tld
_override = _tld.get_model_config_override() if hasattr(_tld, 'get_model_config_override') else None
# `config_list` was never bound in this module -- ruff F821 reports it
# at both sites, so with no thread-local override (the normal case)
# this line raised NameError. Both functions are currently unreached,
# which is why it never surfaced; the cost of leaving it is that this
# file's F821 gate stays red and hides the NEXT undefined name.
# get_autogen_config_list() is the canonical resolver
# (core/autogen_config.py:205), and with_local_fallback's docstring
# already describes this very idiom as "override or config_list".
_clist = _override or get_autogen_config_list()
llm_config = {
"config_list": _clist,
"seed": 42
}
# Create the assistant agent with context awareness
assistant = autogen.AssistantAgent(
name=f"assistant_{user_id}",
llm_config=llm_config,
is_termination_msg=_is_terminate_msg,
system_message="""You are a custom agent bot creator. Your task is to interact with the user to gather all the necessary details to create an agent. Once you have collected all the required information, you will generate a complete agent configuration.
The information you need to collect includes:
Name: The name of the agent.
Purpose: A brief description of the agent's purpose.
Flow: The operational flow of the agent.
Actions: The actions the agent will perform.
Goal: The ultimate goal of the agent.
Guidelines for Responses:
If you are still gathering information, your response should be formatted as: { "status": "pending", "question": "The question you want to ask" }
Once all the requirements are complete and you are ready to provide the agent configuration, your response should be formatted as: { "status": "completed", "name": "","Recipe":"", "purpose": "", "tools": "", "flow": { "actions": [] }, "goal": ""}
the recipe should be containing all the steps
necessary and name for it. Suggest well-documented, generalized python function(s)
only if necessary if the task is achievable without python then dont give python code just give the prompt
to perform similar tasks for coding steps in future. Make sure coding steps and
non-coding steps are never mixed in one function. In the docstr of the function(s),
clarify what non-coding steps are needed to use the language skill of the assistant.
"""
)
# Create the user proxy agent
user_proxy = autogen.UserProxyAgent(
name=f"user_proxy_{user_id}",
human_input_mode="NEVER",
is_termination_msg=_is_terminate_msg,
code_execution_config={"work_dir": get_coding_workspace_dir(), "use_docker": False}
)
return assistant, user_proxy
def get_agent_response(assistant: autogen.AssistantAgent, user_proxy: autogen.UserProxyAgent, message: str) -> str:
"""Get a single response from the agent for the given message."""
try:
# Get the current chat history
current_chat = user_proxy.chat_messages.get(assistant.name, [])
# Create context from previous messages (last 5 messages for efficiency)
context = current_chat[-5:] if current_chat else []
context_str = "\n".join(
[f"{msg['role']}: {msg['content']}" for msg in context])
# Append context to the message if there's history
enhanced_message = message
if context:
enhanced_message = f"Previous conversation:\n{context_str}\n\nCurrent message: {message}"
# Send message and get response
response = user_proxy.send(
enhanced_message,
assistant,
request_reply=True
)
key = list(user_proxy.chat_messages.keys())[0]
return user_proxy.chat_messages[key][-1]['content']
except Exception as e:
return f"Error getting response: {str(e)}"
def create_agents(user_id: str,recipe:str) -> Tuple[autogen.ConversableAgent, autogen.ConversableAgent]:
"""Create new assistant and user agents for a given user_id"""
from hartos.threadlocal import thread_local_data as _tld
_override = _tld.get_model_config_override() if hasattr(_tld, 'get_model_config_override') else None
# `config_list` was never bound in this module -- ruff F821 reports it
# at both sites, so with no thread-local override (the normal case)
# this line raised NameError. Both functions are currently unreached,
# which is why it never surfaced; the cost of leaving it is that this
# file's F821 gate stays red and hides the NEXT undefined name.
# get_autogen_config_list() is the canonical resolver
# (core/autogen_config.py:205), and with_local_fallback's docstring
# already describes this very idiom as "override or config_list".
_clist = _override or get_autogen_config_list()
llm_config = {
"temperature": 0.7,
"config_list": _clist,
}
conversation = True
if conversation:
recipe = recipe+'\n Note: Wait for user confirmation to proceed after every action.'
# Create assistant agent
assistant = autogen.ConversableAgent(
name=f"assistant_{user_id}",
llm_config=llm_config,
is_termination_msg=_is_terminate_msg,
system_message=recipe
)
# Create user agent
user = autogen.ConversableAgent(
name=f"user_{user_id}",
is_termination_msg=_is_terminate_msg,
llm_config=None, # User agent doesn't need LLM
human_input_mode="NEVER", # We'll manually send messages
max_consecutive_auto_reply=1 # Limit to 1 auto reply
)
return user, assistant
else:
# Provide stub functions when autogen is not available
def create_agents_for_user(user_id: str):
raise ImportError("autogen package is not installed")
def get_agent_response(assistant, user_proxy, message: str) -> str:
raise ImportError("autogen package is not installed")
def create_agents(user_id: str, recipe: str):
raise ImportError("autogen package is not installed")
# ---------------------------------------------------------------------------
# G10: Agent-agent ingestion callback
# ---------------------------------------------------------------------------
# Each tool step inside the LangChain agent loop is a mini "agent talking to
# another agent" exchange. HevolveAI treats these just like user<->AI turns:
# they become training data via WorldModelBridge.record_interaction().
# Without this hook the ENTIRE inner reasoning loop was invisible to the
# learner — only the final reply was ingested.
#
# On each tool invocation we record:
# prompt = ""
# response = ""
# model_id = "langchain-tool:"
#
# All calls are best-effort and fire-and-forget — the callback must NEVER
# raise or add latency to the agent loop.
try:
from langchain_classic.callbacks.base import BaseCallbackHandler as _LC_BaseCallbackHandler
except ImportError:
_LC_BaseCallbackHandler = None
if _LC_BaseCallbackHandler is not None:
class AgentInteractionIngestor(_LC_BaseCallbackHandler):
"""Feed every intra-agent tool step to WorldModelBridge as training data.
Correlates each tool invocation by capturing the AgentAction emitted
by ``on_agent_action`` and pairing it with the matching
``on_tool_end`` observation. LangChain may interleave parallel tools
in the future, so we key by ``run_id`` not a single latest slot.
"""
def __init__(self, user_id, prompt_id, node_id=None):
super().__init__()
self._user_id = str(user_id) if user_id is not None else 'anonymous'
self._prompt_id = str(prompt_id) if prompt_id is not None else ''
self._node_id = node_id
self._pending = {} # run_id -> (tool, tool_input, action_log, start_ts)
self._bridge = None
def _get_bridge(self):
if self._bridge is not None:
return self._bridge
try:
from integrations.agent_engine.world_model_bridge import (
get_world_model_bridge,
)
self._bridge = get_world_model_bridge()
except Exception:
self._bridge = False # sentinel meaning "gave up"
return self._bridge if self._bridge else None
def on_agent_action(self, action, *, run_id=None, **kwargs):
try:
rid = str(run_id) if run_id is not None else 'default'
self._pending[rid] = (
getattr(action, 'tool', '') or '',
getattr(action, 'tool_input', '') or '',
getattr(action, 'log', '') or '',
time.time(),
)
except Exception:
logging.getLogger(__name__).exception("on_agent_action: swallowed Exception")
def on_tool_end(self, output, *, run_id=None, **kwargs):
try:
rid = str(run_id) if run_id is not None else 'default'
ctx = self._pending.pop(rid, None)
if ctx is None:
return
tool_name, tool_input, action_log, start_ts = ctx
bridge = self._get_bridge()
if bridge is None:
return
latency_ms = max(0.0, (time.time() - start_ts) * 1000.0)
prompt_text = (
f"[agent-step]\nAction: {tool_name}\n"
f"Input: {str(tool_input)[:2000]}\n"
f"Reasoning: {str(action_log)[:1500]}"
)
response_text = str(output)[:4000]
bridge.record_interaction(
user_id=self._user_id,
prompt_id=self._prompt_id,
prompt=prompt_text,
response=response_text,
model_id=f"langchain-tool:{tool_name}",
latency_ms=latency_ms,
node_id=self._node_id,
)
except Exception:
# Never propagate — training ingestion must not break the agent.
logging.getLogger(__name__).exception("on_tool_end: swallowed Exception")
def on_tool_error(self, error, *, run_id=None, **kwargs):
# Tool errors are ALSO training signal (negative observations).
try:
rid = str(run_id) if run_id is not None else 'default'
ctx = self._pending.pop(rid, None)
if ctx is None:
return
tool_name, tool_input, action_log, start_ts = ctx
bridge = self._get_bridge()
if bridge is None:
return
latency_ms = max(0.0, (time.time() - start_ts) * 1000.0)
prompt_text = (
f"[agent-step]\nAction: {tool_name}\n"
f"Input: {str(tool_input)[:2000]}\n"
f"Reasoning: {str(action_log)[:1500]}"
)
response_text = f"[TOOL_ERROR] {str(error)[:3000]}"
bridge.record_interaction(
user_id=self._user_id,
prompt_id=self._prompt_id,
prompt=prompt_text,
response=response_text,
model_id=f"langchain-tool-error:{tool_name}",
latency_ms=latency_ms,
node_id=self._node_id,
)
except Exception:
logging.getLogger(__name__).exception("on_tool_error: swallowed Exception")
else:
AgentInteractionIngestor = None # noqa: N816
from core.llm_outbound_logger import with_source as _with_source
# main function
@_with_source('langchain.chat')
def get_ans(casual_conv, req_tool, user_id, query, custom_prompt, preferred_lang):
start_time = time.time()
_stage_req_id = str(thread_local_data.get_request_id() or '')
publish_chat_stage('loading_context', user_id=str(user_id), request_id=_stage_req_id)
# casual_conv is the SESSION-SHAPE flag from the route (chatbot_routes:486
# `not bool(prompt_id or create_agent)`): no prompt_id = casual companion
# session (light path), prompt_id = agent-bound session (full tool path).
# It is NOT the draft classifier's per-turn `is_casual` verdict — a
# 2026-08-24 change treated it as such and overrode it in the /chat
# handler; the owner reverted that same day. When True we skip the
# action+profile fetch entirely because a casual acknowledgement doesn't
# consult either. No Python-side classification of `query` happens
# anywhere in the user_context module.
if casual_conv:
user_details = "Casual conversation mode."
actions = ""
app.logger.info("Skipped get_action_user_details (casual_conv=True)")
else:
user_details, actions = get_action_user_details(user_id=user_id)
app.logger.info(
"time taken by get_action_user_details %s seconds", time.time() - start_time)
app.logger.info(casual_conv)
llm = CustomGPT(casual_conv=casual_conv)
app.logger.info(f"query------> {query}")
memory_start_time = time.time()
publish_chat_stage('loading_memory', user_id=str(user_id), request_id=_stage_req_id)
memory = get_memory(user_id=user_id)
app.logger.info("time taken by get_memory %s seconds",
time.time() - memory_start_time)
tools_start_time = time.time()
publish_chat_stage('loading_tools', user_id=str(user_id), request_id=_stage_req_id)
# Timing instrumentation (#185) — splits the previously-monolithic
# "tools_start_time → get_tools_loaded" window so we can see whether
# publish_chat_stage or get_tools is the time sink.
_pcs_done_time = time.time()
app.logger.info(
"[GET_TOOLS_TIMING] publish_chat_stage('loading_tools'): %.2fs",
_pcs_done_time - tools_start_time,
)
# Skip tool loading for casual_conv=True — the 0.8B draft handles
# casual chat without tools, saving ~2.5s of tool registry loading.
if casual_conv:
# Minimal first-pass tools — enough for the LLM to help with common
# tasks (calculator, history, image) without loading the full registry.
tools = get_tools(req_tool=req_tool, is_first=True)
app.logger.info("get_tools loaded (is_first=True, casual_conv=True) %s seconds",
time.time() - tools_start_time)
else:
# Exhaustive intent-based tool set — full registry scan based on
# detected intent. This is the heavy path (~2.5s) but gives the LLM
# access to every tool for complex/agentic requests.
tools = get_tools(req_tool=req_tool, is_first=False)
app.logger.info("get_tools loaded (is_first=False, casual_conv=False) %s seconds",
time.time() - tools_start_time)
app.logger.info(f'tools {type(tools)}')
language = SUPPORTED_LANG_DICT.get(preferred_lang[:2], 'English')
colloquial = True
# Build dynamic identity for fast-path too — same canonical loader as the
# main path (mtime-cached, keyed by prompt_id; replaces the dead stub).
try:
from core.cache_loaders import load_agent_config as _load_agent_config
_fast_agent_config = _load_agent_config(thread_local_data.get_prompt_id())
except Exception:
_fast_agent_config = None
_fast_identity = build_identity_prompt(_fast_agent_config, '', user_details)
# Regional tone + resonance (per-user adaptive tone)
_tone_block = ''
try:
from core.agent_personality import get_regional_tone_prompt
_tone_block = get_regional_tone_prompt(preferred_lang)
except Exception:
logging.getLogger(__name__).exception("get_ans: swallowed Exception")
_resonance_block = ''
try:
from core.resonance_profile import get_or_create_profile
from core.resonance_tuner import build_resonance_prompt, pre_tune_from_input
_res_profile = get_or_create_profile(str(user_id))
# `query` — the USER'S message — not `prompt`. `prompt` is the
# LangChain template built 119 lines below (:7470
# ConversationalChatAgent.create_prompt), and because it is assigned
# anywhere in this function Python made it local for the WHOLE body, so
# this read raised UnboundLocalError on every single turn. The enclosing
# except swallowed it, so pre_tune AND build_resonance_prompt below were
# both skipped and _resonance_block stayed '' forever — the resonance
# feature was silently dead on this path. pre_tune_from_input's own
# signature says what it wants: `user_message: str`.
_res_profile = pre_tune_from_input(_res_profile, query)
_resonance_block = build_resonance_prompt(_res_profile) or ''
except Exception:
logging.getLogger(__name__).exception("get_ans: swallowed Exception")
# Strong language directive — Qwen3.5-4B (and many multilingual
# models) will ignore a weak "respond in English" when the system
# prompt mentions Indian/Asian cultural traits or the user's email/
# name hints at a regional origin. Seen on 2026-04-15: English
# user input "hi" → "Vanakkam! Nan ungal nanban. Hevolve-va romba
# nalla tool, na idea build pannura help pannu..." (Romanised Tamil).
# Fix: explicit script + policy block, placed AFTER identity +
# tone so it wins by recency.
_lang_directive = (
f"\n\nRESPONSE LANGUAGE POLICY (STRICT):\n"
f"- Respond in {language} only. Match the script the USER wrote in.\n"
f"- If the user writes in Latin/English script, respond in Latin/"
f"English script — do NOT transliterate other languages into "
f"Latin letters (no Romanised Tamil/Hindi/etc).\n"
f"- Cultural references are welcome, but the response body must be "
f"in {language}.\n"
if (language or '').lower() == 'english' else ''
)
prefix = f"""{_fast_identity}
Answer questions accurately and respond as quickly as possible in {language}.
{_tone_block}{_resonance_block}
Keep responses under 200 words. Be colloquial and natural - don't always greet or use the user's name.
IMPORTANT: Do NOT re-introduce yourself if you already did in the conversation history below. Continue naturally.
{_lang_directive}
User details: {user_details}
Context: {custom_prompt}
You can help with anything — answering questions, coding, research, teaching, creative writing, data analysis, building agents, brainstorming, and more.
Your responses are conveyed via video with an avatar and text-to-speech.
IMPORTANT: Always respond with valid JSON in a markdown code block with "action" and "action_input" fields.
Previous user actions: {actions}
Conversation History:
"""
if not casual_conv:
suffix = """
Only if this above conversation history is not sufficient to fulfill the user's request then use below FULL_HISTORY tool. Important: If results can be accomplished with above information skip tools section and move to format instructions.
TOOLS
------
Assistant can use tools to look up information that may be helpful in answering the user's
question. The tools you can use are:
{{tools}}
{format_instructions}
always create parsable output."""+f'''
The response should be Colloquial in nature,
The response language should be: {language}'''+"""
Here is the User and AI conversation in reverse chronological order:
USER'S INPUT:
-------------
Latest USER'S INPUT For which you need to respond (consult recent history only when needed for more context): {{{{input}}}}
"""
else:
suffix = """
Only if this above conversation history is not sufficient to fulfill the user's request then use below FULL_HISTORY tool. Important: If results can be accomplished with above information skip tools section and move to format instructions.
TOOLS
------
Assistant can use tools to look up information that may be helpful in answering the user's
question. The tools you can use are:
{format_instructions}
always create parsable output."""+f'''
The response should be Colloquial in nature,
The response language should be: {language}'''+"""
Here is the User and AI conversation in reverse chronological order:
USER'S INPUT:
-------------
Latest USER'S INPUT For which you need to respond (consult recent history only when needed for more context): {{{{input}}}}
"""
TEMPLATE_TOOL_RESPONSE = """TOOL RESPONSE:
---------------------
{observation}
USER'S INPUT
--------------------
Okay, so what is response for this tool. If using information obtained from the tools you must mention it explicitly without mentioning the tool names - I have forgotten all TOOL RESPONSES! Remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else. If the TOOL RESPONSE above is empty, an error, or unhelpful, do NOT conclude the information is unavailable yet - emit an action for a DIFFERENT tool that could obtain it (for example Data_Extraction_From_URL on a relevant site when a search returned nothing). Only give a Final Answer saying it is unavailable after alternative tools have also failed."""
prompt = ConversationalChatAgent.create_prompt(
tools,
system_message=prefix,
human_message=suffix,
input_variables=["input", "agent_scratchpad", "chat_history"]
)
# prompt.input_variables
# chat Agent
llm_chain = LLMChain(
llm=llm,
prompt=prompt,
# memory=memory
)
custom_parser = CustomConvoOutputParser()
agent = ConversationalChatAgent(
llm_chain=llm_chain,
verbose=True,
output_parser=custom_parser,
template_tool_response=TEMPLATE_TOOL_RESPONSE
)
prom_id = thread_local_data.get_prompt_id()
metadata = {"where": {
"jsonpath": '$[*] ? (@.prompt_id == {})'.format(prom_id)}}
agent_chain = CustomAgentExecutor(
agent=agent,
tools=tools,
verbose=True,
memory=memory,
metadata=metadata
)
agent_chain_start_time = time.time()
publish_chat_stage('thinking', user_id=str(user_id), request_id=_stage_req_id)
# G10: Feed every intra-agent tool step into WorldModelBridge so the
# inner reasoning loop (not just the final answer) becomes training data.
_ingestor_callbacks = None
# Global suppression flag: once we've proven that the running langchain_classic
# does NOT accept `callbacks=` on `.run()`, keep going but skip attempting it
# again. Without this we'd hit the TypeError on every chat turn and still
# record/log the silent-drop metric every time. The metric counter captures
# the fallback events so a /metrics endpoint can expose G10 ingestion health.
if AgentInteractionIngestor is not None and not getattr(
get_ans, '_g10_callbacks_unsupported', False):
try:
_ingestor_callbacks = [AgentInteractionIngestor(
user_id=user_id,
prompt_id=prom_id,
)]
except Exception:
_ingestor_callbacks = None
try:
try:
if _ingestor_callbacks is not None:
ans = agent_chain.run(
{'input': query}, callbacks=_ingestor_callbacks)
else:
ans = agent_chain.run({'input': query})
except TypeError as _cb_te:
# Older langchain_classic does not accept callbacks on .run().
# Silently swallowing this caused G10 ingestion to disable globally
# with zero alarm (see /api/admin/metrics — g10_silent_drops).
# Log once, flip the suppression flag, bump the counter, and fall
# back so the agent call still succeeds. Also emit to the
# centralized exception_collector so SelfHealingDispatcher /
# ExceptionWatcher pick up the pattern and can surface it in the
# operator /health feed — a future schema drift must not silently
# degrade G10 ingestion a second time. Intentionally NOT
# broadened to bare Exception: the goal is graceful-degrade on a
# known langchain-version mismatch, not hide every failure.
get_ans._g10_callbacks_unsupported = True
get_ans._g10_silent_drops = (
getattr(get_ans, '_g10_silent_drops', 0) + 1)
_payload_preview = (str(query or ''))[:100]
app.logger.warning(
"[G10] agent_chain.run(callbacks=) raised TypeError; "
"G10 training ingestion disabled for this process. "
"exc=%r payload=%r count=%d",
_cb_te, _payload_preview, get_ans._g10_silent_drops,
extra={
'component': 'AgentInteractionIngestor',
'action': 'record_interaction_failed',
'silent_drops': get_ans._g10_silent_drops,
})
try:
from hartos.exception_collector import record_exception
record_exception(
_cb_te, module=__name__, function='get_ans',
component='AgentInteractionIngestor',
action='record_interaction_failed',
silent_drops=get_ans._g10_silent_drops,
payload_preview=_payload_preview,
)
except Exception:
# Fire-and-forget — never let telemetry break the agent.
logging.getLogger(__name__).exception("get_ans: swallowed Exception")
ans = agent_chain.run({'input': query})
except Exception as _agent_err:
# G13: Report text-modality generation failures to the learner so
# the modality router can re-weight future routing decisions.
# submit_output_feedback(status='error') lands on /v1/corrections and
# also fires the C1 hive-hint lookup. Fire-and-forget: never mask
# the underlying agent error.
try:
from integrations.agent_engine.world_model_bridge import (
get_world_model_bridge as _g_wmb,
)
_bridge = _g_wmb()
_elapsed_s = time.time() - agent_chain_start_time
_bridge.submit_output_feedback(
output_modality='text',
status='error',
context=(query or '')[:2000],
model_used='langchain-agent',
error_message=f'{type(_agent_err).__name__}: {str(_agent_err)[:500]}',
generation_time_seconds=float(_elapsed_s),
user_id=str(user_id),
)
except Exception as _fb_err:
app.logger.debug(
f"[get_ans] submit_output_feedback skipped: {_fb_err}")
raise
app.logger.info("time taken by chain agent run %s seconds",
time.time() - agent_chain_start_time)
publish_chat_stage('finalizing', user_id=str(user_id), request_id=_stage_req_id)
# G13 (success-side): empty / trivial / fallback replies are ALSO weak
# text outputs worth tagging for router adjustment. We only flag truly
# empty or the sentinel "model_not_available" / exception-shaped strings
# — genuine short replies shouldn't be penalised.
try:
_ans_text = str(ans or '').strip()
_looks_empty = (len(_ans_text) == 0)
_looks_error = any(
marker in _ans_text.lower()
for marker in ('model_not_available', 'internal server error',
'[tool_error]', 'exception:', 'traceback')
)
if _looks_empty or _looks_error:
from integrations.agent_engine.world_model_bridge import (
get_world_model_bridge as _g_wmb2,
)
_bridge2 = _g_wmb2()
_bridge2.submit_output_feedback(
output_modality='text',
status='error',
context=(query or '')[:2000],
model_used='langchain-agent',
error_message='empty_or_degenerate_reply' if _looks_empty
else f'error_markers_in_reply: {_ans_text[:300]}',
generation_time_seconds=float(
time.time() - agent_chain_start_time),
user_id=str(user_id),
)
except Exception as _fb_err2:
app.logger.debug(
f"[get_ans] success-side feedback skipped: {_fb_err2}")
end_time = time.time()
elapse_time = end_time-start_time
app.logger.info(
f"total time taken by get_ans function %s seconds", elapse_time)
return ans
Hevolve = "You are Hevolve — a place where everything is possible. A personal AI by HertzAI that runs locally, respecting privacy. You help users BUILD — code, ideas, businesses, knowledge, agents, art, solutions, and anything they imagine. You are a builder's companion."
PROBE_TEMPLATE = ("You are Hevolve, a versatile personal AI developed by HertzAI that runs locally on the user's device. Weave the conversation "
"history along with the Last_5_Minutes_Visual_Context if present to create a clear, engaging, "
"coherent conversation flow that encourages the user to respond. Complete your response in 130 words"
"Your response should not be more than 130 words. Neither repeat the previous "
"responses nor be monotonous, be creative and talk about intriguing awe-inspiring facts, "
"or with some interesting age appropriate casual conversations which will make you the single point "
"of contact for everything in the world. Greet if & only if the context demands you to, "
"build a dialogue, use user\'s name only when necessary, Do not sound robotic. If the user is not "
"actively engaging or if visual context is present but user not visible or if user visible but not "
"looking at camera (based on visual and conversation history timestamps) call out their name loud "
"or try singing a song to bring back their attention using a SEEK_ATTENTION tool with input like a "
"song made of knowledge based on user's age, or calling their name loud e.g. tool input: "
"\'Hey , are you there\' or "
"\'Some awesome lyrics\' . Continue the Conversation from "
"where I or you left off."
)
INTERMEDIATE_CONTINUATION = "You are Hevolve, a versatile personal AI developed by HertzAI that runs locally on the user's device. Continue your response from where you left off in the last conversation, considering the new input as a continuation of the last request. Ensure a smooth transition from the previous response and start this response as a continuation of the previous one.\n INSTRUCTIONS: Start your response with transitional words or phrases that can be used as a continuation of the previous response."
first_promts = []
review_agents = {} # keyed by f'{user_id}_{prompt_id}' to avoid cross-prompt collision
conversation_agent = {} # keyed by f'{user_id}_{prompt_id}'
_state_lock = threading.Lock() # Protects review_agents, conversation_agent, first_promts
# --- Interactive gather_info turn limit ---
# After MAX_GATHER_TURNS without completion, force-complete with available data
MAX_GATHER_TURNS = 12
_gather_turn_counts = {} # f'{user_id}_{prompt_id}' -> int
# What the LAST reply of a gather session showed the person: the config
# shown (what _config_review_text rendered), or None for the model's own
# review_details, which is text. Absent after any other reply (a question,
# a request for the steps, a save), so a plain yes is read against what the
# person just saw and nothing else: it builds the config shown, as shown,
# without asking the model; the model's completed config is built when its
# steps are the ones shown (#205). Same keys and lifetime as the turn
# counts.
_gather_reviews_shown = {}
_NO_REVIEW_SHOWN = object() # the mark's value when the session has none
# --- TTL-based cleanup for review_agents / conversation_agent (M2 fix) ---
_AGENT_TTL = 3600 # 1 hour
_agent_timestamps = {} # f'{user_id}_{prompt_id}' -> last-access epoch
def _touch_agent_timestamp(agent_key):
"""Record that an agent entry was accessed (call under _state_lock)."""
_agent_timestamps[agent_key] = time.time()
def _cleanup_stale_agents():
"""Remove agent state entries not accessed in the last _AGENT_TTL seconds.
Must be called *outside* _state_lock or inside an already-acquired lock.
Safe to call frequently -- it is O(n) over active users only.
"""
now = time.time()
for key in list(_agent_timestamps.keys()):
if now - _agent_timestamps[key] > _AGENT_TTL:
review_agents.pop(key, None)
conversation_agent.pop(key, None)
_agent_timestamps.pop(key, None)
# Also clean stale gather turn counters
# Both _gather_turn_counts and _agent_timestamps now use '{user_id}_{prompt_id}' keys
for tk in list(_gather_turn_counts.keys()):
if tk not in _agent_timestamps:
_gather_turn_counts.pop(tk, None)
for tk in list(_gather_reviews_shown):
if tk not in _agent_timestamps:
_gather_reviews_shown.pop(tk, None)
# Per-user locks to prevent race conditions when concurrent requests
# for the same user modify review_agents / conversation_agent dicts.
_user_locks = {}
_user_locks_lock = threading.Lock()
def _get_user_lock(user_key):
"""Get or create a per-user lock to serialize state mutations."""
with _user_locks_lock:
if user_key not in _user_locks:
_user_locks[user_key] = threading.Lock()
return _user_locks[user_key]
def _review_proposed_plan(plan, max_rounds_remaining):
"""StatusVerifier-style auto-reviewer for HART OS autonomous flow.
Single-shot local-LLM call against the same Qwen3-VL endpoint that
gather_info uses — same model competence as the plan author. Applies
quality gates and returns ('approved', None) or
('needs_refinement', feedback_text).
Quality gates (mirrors StatusVerifier verdict shape):
1. Each step is ONE concrete tool call; a genuine multi-step GUI task is
fully decomposed (not crammed into a vague mega-step). Tool-satisfiable
tasks are complete at the few calls they need — no arbitrary min count.
2. Browser/click/type/screenshot/post actions invoke
execute_windows_or_android_command
3. No banned phrases: 'ask the user', 'TODO', '...'
4. Steps in logical order (open before click, login before post)
5. Tool names in the registered catalog
Fail-open: if the reviewer endpoint is down, returns 'approved' so a
reviewer outage doesn't block the autonomous flow indefinitely. The
autogen Helper team applies stricter checks again at execution time.
"""
try:
from core.port_registry import get_local_llm_url
review_prompt = (
"You are HART OS StatusVerifier reviewing an agent plan.\n"
"Quality gates:\n"
" 1. Each step is ONE concrete tool call. A task fully satisfied by a\n"
" few dedicated-tool calls (e.g. ONE get_chat_history for a recall,\n"
" or google_search + send_message_to_user) is COMPLETE — do NOT\n"
" demand a minimum step count. ONLY reject for UNDER-decomposition:\n"
" a genuine multi-step GUI task crammed into one vague step (e.g.\n"
" 'post to LinkedIn' as a single step instead of open/navigate/\n"
" click/type/post). A real GUI automation task has many atomic\n"
" execute_windows steps; there is NO MAXIMUM.\n"
" 2. GUI / desktop actions ONLY: actions mentioning browser/click/\n"
" screenshot/navigate-url MUST invoke 'execute_windows_or_android_command'.\n"
" Words like 'type', 'input', 'post', 'open' alone do NOT require\n"
" this tool — chat-side user input uses send_message_to_user; code\n"
" generation is inline in the response.\n"
" 3. No banned phrases: 'ask the user', 'TODO', 'etc.', '...'\n"
" 4. Steps in logical order (open before click, login before post).\n"
" 5. Tool names match the registered catalog.\n\n"
"IMPORTANT: the plan JSON has already been validated and sanitized\n"
"upstream by helper.retrieve_json (4-tier fallback: json_repair package,\n"
"ast.literal_eval, regex, remote fix_json HTTP). Do NOT report JSON\n"
"formatting issues — focus on plan CONTENT quality only. If you think\n"
"you see a malformed brace, stray quote, or syntax error, assume it's\n"
"a serialization artifact and ignore it.\n\n"
"Plan to review (JSON):\n"
# Whole, never cut: a task that names its own flows keeps one
# flow per named flow, and seven flows of nine real steps come
# to about 8.9k even compact, so the old 6000 cut ended the plan
# mid-JSON and the reviewer judged a different plan. No cap is
# needed for the context: the plan is this same endpoint's own
# output, written after a longer prompt than these rules, and a
# call that still fails is fail-open below. Compact for tokens.
+ json.dumps(plan, separators=(',', ':'))
+ "\n\nRespond with ONLY a JSON object:\n"
" {\"status\":\"approved\"} if all gates pass\n"
" {\"status\":\"needs_refinement\",\"flow_name\":\"the flow to fix\",\"feedback\":\"specific fix needed\"} otherwise\n"
"ASCII only. No prose. Start with { end with }."
)
# Reuse pooled_post that gather_info uses; same endpoint
r = pooled_post(
GPT_API,
json={
'model': LLM_MODEL_NAME,
'messages': [{'role': 'user', 'content': review_prompt}],
'temperature': 0.0,
'max_tokens': 256,
},
headers=LLM_AUTH_HEADERS,
timeout=60,
)
text = r.json()['choices'][0]['message']['content']
verdict = retrieve_json(text)
if isinstance(verdict, dict):
if verdict.get('status', '').lower() == 'approved':
app.logger.info('Plan review: approved')
return ('approved', None)
fb = verdict.get('feedback') or 'plan needs more atomic detail'
# The planner applies feedback to the flow it names
# (gather_agentdetails' autonomous prompt), so the flow travels
# inside the feedback text, the one thing passed back.
_flow = verdict.get('flow_name')
if isinstance(_flow, str) and _flow.strip():
fb = f"In flow '{_flow.strip()}': {fb}"
app.logger.info(f'Plan review: needs_refinement — {fb[:200]}')
return ('needs_refinement', fb)
# Couldn't parse — fail-open
app.logger.debug('Plan review: unparseable, fail-open')
return ('approved', None)
except Exception as e:
# Reviewer outage — fail-open (Helper team's strict checks still apply
# at execution time). Don't block flywheel on reviewer health.
app.logger.warning(f'Plan review failed (fail-open): {e}')
return ('approved', None)
def _approved_plan_response(plan):
"""The approved ``plan`` as the response the save path reads: status
'completed', review_required dropped, nothing else changed.
Finalized here, in code. Sending 'approved' to gather_info and saving
its re-emission saved a new LLM answer, not the reviewed plan: on
2026-10-30 the Syllable Coach (prompt 1793359802508) was saved with
"read Arjun's latest attempt" ahead of "ask him to say ball", reworded,
though all three staged rounds kept the creator's order (hartos-c9).
"""
final = {k: v for k, v in plan.items() if k != 'review_required'}
final['status'] = 'completed'
return json.dumps(final)
def _stage_review_round(plan, prompt_id, review_round):
"""Write one auto-review round's plan to its proposed-plan file (audit)."""
try:
from core.prompt_files import proposed_plan_filename
_plan_path = os.path.join(
PROMPTS_DIR,
proposed_plan_filename(prompt_id, review_round))
os.makedirs(os.path.dirname(_plan_path), exist_ok=True)
with open(_plan_path, 'w') as f:
json.dump(plan, f)
app.logger.info(
f'Plan round {review_round} staged at {_plan_path}')
except Exception as e:
app.logger.warning(f'Plan stage write failed: {e}')
def _plan_name(plan, prompt_id):
plan = plan if isinstance(plan, dict) else {}
return str(plan.get('name') or plan.get('agent_name') or f'agent {prompt_id}')
def _write_staged_plan(prompt_id, plan) -> None:
"""The one writer of the plan staged under ``prompt_id``, held or ended
(core.prompt_files.staged_plan reads it back). Raises on failure; the
callers say what was lost."""
from core.prompt_files import proposed_plan_filename
path = os.path.join(PROMPTS_DIR, proposed_plan_filename(prompt_id))
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
json.dump(plan, f)
def _hold_for_the_creator(plan, prompt_id, user_id, why, background):
"""A plan nobody approved waits on its creator's approval card; it is
never saved unapproved (owner 11-04: the whole plan reviewed, no forced
approval). Before this the oscillation guard and the three-round
escalation saved it as it stood (live 11-07, agent 1794031134533).
The plan is staged as the peer-review plan with the ask kept in it
(core.prompt_files.staged_plan reads it back). A person's create also
keeps a hold under the CREATING user, one per person, because the person
paths mint a new prompt id on every create (_creation_prompt_id); a
background create resumes by its goal's own stable prompt id and keeps
none, since one daemon persona serves many goals (hartos-16, 11-07). The
card's buttons are the ones every client draws: approve builds the plan,
deny drops it. An ask that could not be filed (ok False: a database
lock on a daemon tick) still holds, and the next turn asks again. What
to tell the creator."""
from integrations.agent_engine import person_ask
from core.prompt_files import hold_plan
held = {k: v for k, v in plan.items() if k not in ('review_required', 'hold')}
held.update(status='proposed_plan', prompt_id=prompt_id, creator_user_id=user_id)
name = _plan_name(held, prompt_id)
card = {'type': 'approval', 'action': f'approve_plan:{prompt_id}',
'description': (f'The plan for {name} did not settle in review ({why}). '
f'Approve to build it on your next message, or deny to drop it.')}
asked, out = None, {'ok': False, 'refused': 'no_person'}
try:
asked = person_ask.person_to_ask(user_id)
if asked:
out = person_ask.ask(asked, prompt_id, card, prompt_id=prompt_id,
background=background)
except Exception as e:
app.logger.warning(f'Plan {prompt_id}: the approval could not be asked: {e}')
out = {'ok': False, 'refused': 'error'}
held['hold'] = {'ask_id': out.get('ask_id') if out.get('ok') else None,
'asked': asked, 'why': why}
try:
_write_staged_plan(prompt_id, held)
if not background:
hold_plan(user_id, prompt_id)
except Exception as e:
app.logger.warning(f'Plan {prompt_id}: the hold could not be kept: {e}')
app.logger.warning(
f'Plan {prompt_id} held for approval ({why}): ask {held["hold"]["ask_id"]} to {asked}'
+ ('' if out.get('ok') else f'; not filed ({out.get("refused")}), asked again next turn'))
return (f'The plan for {name} did not settle in review ({why}), so it waits for '
f'your approval: approve it on the card and it is built on your next '
f'message, or deny it to drop it.')
def _held_plan_turn(user_id, prompt_id, background):
"""A creator's turn on a plan of ``prompt_id`` that waits on their card:
('approved', response), the staged plan finalized in code, never a
re-emission; ('reply', text), what to tell them (still waiting, dropped,
or asked again because the answer is no longer kept); None when no plan
of this id waits."""
from integrations.agent_engine import person_ask
from core.prompt_files import clear_held_plan, staged_plan
staged = staged_plan(PROMPTS_DIR, prompt_id)
hold = (staged or {}).get('hold')
if not isinstance(hold, dict):
return None
name = _plan_name(staged, prompt_id)
plan = {k: v for k, v in staged.items() if k != 'hold'}
def _end():
try:
_write_staged_plan(prompt_id, plan)
except Exception as e:
app.logger.warning(f'Plan {prompt_id}: the ended hold could not be written: {e}')
if not background:
clear_held_plan(user_id)
state = {'status': None}
if hold.get('ask_id') and hold.get('asked'):
try:
state = person_ask.ask_state(hold['ask_id'], hold['asked'])
except Exception as e:
app.logger.warning(f'Plan {prompt_id}: its approval could not be read: {e}')
status = state.get('status')
decision = str((state.get('answer') or {}).get('decision') or '').strip().lower()
if status == 'answered' and decision == 'approve':
_end()
app.logger.info(f'Plan {prompt_id} approved on its card; building the staged plan')
return 'approved', _approved_plan_response(plan)
if status == 'dismissed' or (status == 'answered' and decision == 'deny'):
_end()
app.logger.info(f'Plan {prompt_id} dropped on its card')
return 'reply', (f'Dropped the plan for {name}. Tell me what you want and '
f'I will plan it again.')
if status == 'open':
return 'reply', (f'Your plan for {name} is waiting on the card: approve it '
f'to build it, or deny it to drop it.')
# The answer is no longer kept (an hour passed, a restart), or the ask
# was never filed: ask again; nothing is built without an approval.
return 'reply', _hold_for_the_creator(plan, prompt_id, user_id,
hold.get('why') or 'review did not settle',
background)
def _creation_prompt_id(user_id, proposed):
"""The id a PERSON's create runs under: the plan waiting on their card
when they have one (one per person, core.prompt_files.held_plan), else
``proposed``. Not for background creates (:11033 keeps its goal's id).
A hold record whose plan no longer waits is cleared, never followed."""
from core.prompt_files import clear_held_plan, held_plan, staged_plan
try:
held = held_plan(user_id)
if held is None:
return proposed
if isinstance((staged_plan(PROMPTS_DIR, held) or {}).get('hold'), dict):
return held
clear_held_plan(user_id)
except Exception as e:
app.logger.warning(f'Plan hold of {user_id} could not be read: {e}')
return proposed
def _autonomous_gather_info(user_id, description, prompt_id, background=False):
"""Run gather_info autonomously with HART OS peer-review pattern.
Four-stage flow (matches gather_agentdetails.py PLAN_FIRST prompt
+ StatusVerifier auto-review wired in this function):
STAGE 1: gather_info emits proposed_plan with atomic steps in
flows[].actions[].
STAGE 2 (auto, no human in loop): _review_proposed_plan calls
a single-shot StatusVerifier-style LLM review against quality
gates. Returns 'approved' or 'needs_refinement' + feedback.
Every plan is reviewed, including one the model marked
'completed' itself.
STAGE 3 (auto): if needs_refinement, send feedback to gather_info
which refines and re-emits. Loop up to 3 rounds. After 3
rounds, the newest plan (the refinement that answered the last
feedback, never reviewed) is staged as its own round and saved
as it stands; an oscillating plan is saved the same way. A
refinement call that fails leaves the rejected plan proposed,
handed to a peer reviewer, never saved. Asking the owner
instead is a follow-up.
STAGE 4: on approved, the approved plan itself is finalized in code
(_approved_plan_response, no LLM call) → status 'completed' →
persona JSON saved → next /chat call
dispatches to the autogen multi-agent team (Helper + Executor
+ StatusVerifier) which executes the atomic steps using
execute_windows_or_android_command and saves per-step recipe
files for future REUSE-mode replay.
This closes the autonomous loop without a human in the path —
addressing the gap exposed on 2026-05-16 where /chat with
create_agent=true stalled at 'Review Mode' because no approver
was wired. StatusVerifier is the canonical reviewer agent
(helper.py:2025) and the same Qwen3-VL endpoint that gather_info
uses handles the review verdict at zero marginal cost.
"""
from hartos.gather_agentdetails import gather_info
# A plan of this id waiting on its creator's card is answered first.
_held = _held_plan_turn(user_id, prompt_id, background)
if _held is not None and _held[0] == 'reply':
return _held[1]
_resumed = _held is not None
response = (_held[1] if _resumed
else gather_info(user_id, description, prompt_id, autonomous=True))
# Auto-review + refinement loop (no human in path)
review_rounds = 0
MAX_REVIEW_ROUNDS = 3
# Oscillation detection (#palindrome-cycle-2026-05-28): the
# log evidence from request_id c4b09d1f-… showed the planner
# producing action counts of 12 → 2 → 10 → 9 across 4 rounds —
# the reviewer's contradictory feedback (round 1 said "12
# exceeds 5-step LIMIT", round 2 said "2 violates >= 5
# REQUIREMENT") was yo-yoing the planner instead of converging.
# When the action count swings by more than _OSC_DELTA between
# consecutive proposed_plans, the reviewer is not converging;
# burning more rounds wastes ~10s each. Escalate immediately.
# Deterministic oscillation guard — extracted to a tiny pure module so it's
# unit-tested in isolation (#57, Gate 5). Behaviour unchanged.
from core.plan_oscillation import (
plan_action_count as _plan_action_count,
is_plan_oscillating as _is_plan_oscillating,
OSC_DELTA as _OSC_DELTA,
)
_prev_action_count = None
_approved = _resumed # approved on its card: finalized, saved below
while review_rounds < MAX_REVIEW_ROUNDS:
try:
parsed = _parse_plan_reply(response)
if isinstance(parsed, list):
for item in reversed(parsed):
if isinstance(item, dict) and 'status' in item:
parsed = item
break
else:
parsed = {}
if not isinstance(parsed, dict):
break
_status = (parsed.get('status') or '').lower()
if _status == 'completed' and _approved:
# The plan the reviewer approved, finalized; saved below.
break
if _status == 'completed':
# A plan the model marked completed that no reviewer
# approved is reviewed like any other. The autonomous
# system message's STAGE 1 has the first call emit a plan
# for review, so 'completed' at round 0 or on a refinement
# is the model skipping review; it was saved unreviewed and
# unstaged (review of a88258c98, F-d).
_status = 'proposed_plan'
# The reply itself is relabelled: every exit below reads
# `response`, and a break after a failed refinement saved
# this unreviewed plan as 'completed' while the log said
# "not saved" (review of 7e130b0df, must-fix 2).
parsed['status'] = 'proposed_plan'
response = json.dumps(parsed)
if _status == 'proposed_plan':
# Oscillation guard — must run BEFORE the reviewer is
# called for this round. Cheap dict-walk on the
# already-parsed plan.
_action_count = _plan_action_count(parsed)
if _is_plan_oscillating(_prev_action_count, _action_count):
app.logger.warning(
f'Plan oscillation detected (round {review_rounds}): '
f'action_count {_prev_action_count} -> {_action_count} '
f'(delta {abs(_action_count - _prev_action_count)} > '
f'threshold {_OSC_DELTA}). Reviewer is yo-yoing the '
f'planner. Holding the most recent plan for its '
f'creator\'s approval to stop the cycle.'
)
# Never reviewed, so staged as its own round, like the
# escalation's (review of cdeca7500, F4), and held for
# the creator's approval, never saved as it stands.
parsed['prompt_id'] = prompt_id
parsed['creator_user_id'] = user_id
_stage_review_round(parsed, prompt_id, review_rounds)
return _hold_for_the_creator(
parsed, prompt_id, user_id,
'the reviewer and the planner kept changing the step count',
background)
_prev_action_count = _action_count
# Stage plan for audit + run auto-review
parsed['prompt_id'] = prompt_id
parsed['creator_user_id'] = user_id
_stage_review_round(parsed, prompt_id, review_rounds)
verdict, feedback = _review_proposed_plan(
parsed, MAX_REVIEW_ROUNDS - review_rounds)
if verdict == 'approved':
# The plan the reviewer approved is the one saved.
app.logger.info(
f'Plan approved by StatusVerifier after '
f'{review_rounds + 1} rounds, finalizing')
_approved = True
response = _approved_plan_response(parsed)
continue # re-parse on next iteration: completed
else:
# Send feedback to gather_info for refinement. A
# refinement call that fails (the model down) ends the
# loop before the round is counted, so the plan the
# reviewer rejected stays a proposed plan and is never
# saved; counted first, the escalation saved it as the
# "never reviewed" newest (review of a88258c98, F-c).
# gather_info does not raise on a model failure: it
# returns the failed turn as a sentence ("I couldn't
# finish that: ..."), which counted as round 3 and let the
# escalation save the plan just rejected (review of
# 7e130b0df, must-fix 1, measured with the real
# gather_info). Either form ends the loop uncounted.
from core.agent_tools import is_user_facing_error
try:
refined = gather_info(user_id, feedback,
prompt_id, autonomous=True)
_failed = refined if is_user_facing_error(refined) else None
except Exception as e:
_failed = str(e)
if _failed is not None:
app.logger.warning(
f'Plan refinement round {review_rounds + 1} failed: '
f'{str(_failed)[:200]}; the plan the reviewer '
f'rejected stays proposed, not saved')
break
review_rounds += 1
response = refined
continue
# Unknown status — exit the auto-review loop, fall to legacy below
break
except (json.JSONDecodeError, AttributeError, Exception) as e:
app.logger.debug(
f'Auto-review round {review_rounds} parse error: {e}')
break
# After MAX_REVIEW_ROUNDS without convergence, the newest plan (the
# refinement that answered the last feedback, never reviewed) is staged
# as its own round and saved as it stands, whether the model marked it
# 'proposed_plan' or 'completed' (review of cdeca7500, F1: a 'completed'
# refinement was re-asked and the re-emission saved). Only when the
# last answer holds no plan is gather_info asked to approve, as before.
if review_rounds >= MAX_REVIEW_ROUNDS:
try:
_newest = _parse_plan_reply(response)
if isinstance(_newest, list):
_newest = next((item for item in reversed(_newest)
if isinstance(item, dict) and 'status' in item), {})
except Exception:
_newest = None
if (isinstance(_newest, dict)
and (_newest.get('status') or '').lower() in ('proposed_plan',
'completed')):
app.logger.warning(
f'Plan review hit {MAX_REVIEW_ROUNDS} rounds without '
f'convergence: holding the newest plan, round {review_rounds}, '
f'which was never reviewed, for its creator\'s approval')
_newest['prompt_id'] = prompt_id
_newest['creator_user_id'] = user_id
_stage_review_round(_newest, prompt_id, review_rounds)
return _hold_for_the_creator(
_newest, prompt_id, user_id,
f'{MAX_REVIEW_ROUNDS} review rounds did not agree', background)
else:
app.logger.warning(
f'Plan review hit {MAX_REVIEW_ROUNDS} rounds without '
f'convergence and the last answer holds no plan: asking '
f'gather_info to approve')
try:
response = gather_info(user_id, 'approved (escalated after 3 review rounds)',
prompt_id, autonomous=True)
except Exception as e:
app.logger.error(f'Escalation gather_info failed: {e}')
# Single-shot: LLM should emit a complete plan on first call.
# If status is "proposed_plan" we return the plan to the peer
# reviewer. If "completed" we save + dispatch. Anything else
# (including stale "pending" Q&A behavior) falls back to the
# legacy partial-config rescue at end of this function.
try:
parsed = _parse_plan_reply(response)
if isinstance(parsed, list):
for item in reversed(parsed):
if isinstance(item, dict) and 'status' in item:
parsed = item
break
else:
parsed = {}
if isinstance(parsed, dict):
_status = (parsed.get('status') or '').lower()
if _status == 'proposed_plan':
# A plan the review could not finish (its model failed) waits
# on its creator's card like the other two; the card is the
# only approval. The reply used to ask for a free-text
# "approved" that nothing read (hartos-16, 11-07).
return _hold_for_the_creator(
parsed, prompt_id, user_id, 'the review could not finish',
background)
# Only a config that can be built is saved as built (#206); one
# that cannot falls to the legacy loop below, as unfinished.
_built = (_buildable_completed_config(parsed, prompt_id)
if _status == 'completed' else None)
if _built is not None:
# Save final agent config and dispatch
parsed = _built
parsed['prompt_id'] = prompt_id
parsed['creator_user_id'] = user_id
name = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
with open(name, 'w') as f:
json.dump(parsed, f)
app.logger.info(f'Autonomous agent config saved to {name}')
# Sync to cloud DB so prompt_id matches
try:
pooled_post(
f'{DB_URL}/createpromptlist',
json={'listprompts': [{
'prompt_id': prompt_id,
'prompt': parsed.get('goal', ''),
'user_id': user_id,
'name': parsed.get('name', ''),
'is_active': True,
'image_url': parsed.get('image_url', ''),
}]},
timeout=5)
except Exception as e:
app.logger.debug(f"Cloud sync failed (non-fatal): {e}")
return 'Agent details gathered autonomously. Moving to review.'
except (json.JSONDecodeError, AttributeError, Exception) as e:
app.logger.debug(f'Autonomous gather plan-parse: {e}')
# Legacy fallback only — old prompt would loop with "proceed".
# We keep the loop but cap at fewer iterations now that plan-first
# mode is primary; this only fires when an LLM ignores the new
# PLAN_FIRST instructions (e.g. older Qwen variants).
max_iterations = 5
iteration = 0
while iteration < max_iterations:
try:
parsed = _parse_plan_reply(response)
if isinstance(parsed, list):
for item in reversed(parsed):
if isinstance(item, dict) and 'status' in item:
parsed = item
break
else:
parsed = {}
_built = _buildable_completed_config(parsed, prompt_id)
if _built is not None:
parsed = _built
parsed['prompt_id'] = prompt_id
parsed['creator_user_id'] = user_id
name = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
with open(name, 'w') as f:
json.dump(parsed, f)
app.logger.info(f'Autonomous agent config saved to {name}')
try:
pooled_post(
f'{DB_URL}/createpromptlist',
json={'listprompts': [{
'prompt_id': prompt_id,
'prompt': parsed.get('goal', ''),
'user_id': user_id,
'name': parsed.get('name', ''),
'is_active': True,
'image_url': parsed.get('image_url', ''),
}]},
timeout=5)
except Exception as e:
app.logger.debug(f"Cloud sync failed (non-fatal): {e}")
return 'Agent details gathered autonomously. Moving to review.'
except (json.JSONDecodeError, AttributeError, Exception) as e:
app.logger.debug(f'Autonomous gather iteration {iteration}: {e}')
# Not complete yet — send auto-continue
response = gather_info(user_id, 'proceed', prompt_id, autonomous=True)
iteration += 1
# Fallback: save partial config so the pipeline can recover
app.logger.warning(f'Autonomous gather_info did not complete in {max_iterations} iterations, saving partial config')
partial = {
'status': 'pending', # NOT completed: creation FAILED (#718)
'name': f'Agent {prompt_id}',
'agent_name': f'auto.agent{str(prompt_id)[-4:]}',
'goal': description or 'General assistant',
'broadcast_agent': 'no',
'personas': [{'name': 'Assistant', 'description': 'General purpose assistant'}],
'flows': [{'flow_name': 'main', 'persona': 'Assistant', 'actions': ['Respond to user'], 'sub_goal': description or 'Help the user'}],
'extra_information': f'Auto-generated after {max_iterations} autonomous gather iterations',
'prompt_id': prompt_id,
'creator_user_id': user_id,
}
name = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
try:
with open(name, 'w') as f:
json.dump(partial, f)
app.logger.info(f'Partial autonomous agent config saved to {name}')
# Sync to cloud DB (non-fatal)
try:
pooled_post(
f'{DB_URL}/createpromptlist',
json={'listprompts': [{
'prompt_id': prompt_id,
'prompt': partial.get('goal', ''),
'user_id': user_id,
'name': partial.get('name', ''),
'is_active': True,
'image_url': '',
}]},
timeout=5)
except Exception:
logging.getLogger(__name__).exception("_autonomous_gather_info: swallowed Exception")
except Exception as e:
app.logger.error(f'Failed to save partial autonomous config: {e}')
return 'Autonomous gathering completed with partial config. Moving to review.'
# Resonance tuning post-response hook
def _tune_resonance_after_chat(user_id, prompt_text, response_text):
"""Tune resonance and return summary for crossbar/response piggyback.
Returns dict with resonance state (or empty dict on failure).
Sync — EMA math is <1ms, no LLM calls.
Also dispatches signals to HevolveAI async in background.
"""
try:
from core.resonance_tuner import get_resonance_tuner
if user_id and prompt_text and response_text:
tuner = get_resonance_tuner()
# EMA math is <1ms — do it synchronously for the response payload.
# HevolveAI dispatch (HTTP/bridge) is async — never blocks response.
profile = tuner.analyze_and_tune(
str(user_id), str(prompt_text), str(response_text))
return {
'resonance_confidence': round(profile.resonance_confidence, 2),
'resonance_tuning': {
k: round(v, 3) for k, v in profile.tuning.items()
},
'resonance_interactions': profile.total_interactions,
}
except ImportError:
logging.getLogger(__name__).debug("_tune_resonance_after_chat: swallowed ImportError")
except Exception:
logging.getLogger(__name__).exception("_tune_resonance_after_chat: swallowed Exception")
return {}
_tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='tts_async')
# --- Language persistence (DRY: one write path, core.user_lang) ---
def _persist_language(lang: str) -> bool:
"""Thin shim for backward compatibility. Real implementation is in
core.user_lang.set_preferred_lang — atomic write + on_lang_change
subscriber bus (for draft eviction etc.).
Kept as a named function here because older code in this file +
downstream refs import it; new code should call the core API
directly."""
from core.user_lang import set_preferred_lang
return set_preferred_lang(lang)
def _vision_keyword_override(prompt: str) -> bool:
"""Safety net that overrides the draft classifier's `is_casual`
verdict when the user's prompt clearly needs vision.
Thin wrapper — the canonical keyword set + compiled regex live
in core.constants.VISION_INTENT_PATTERN so the single source of
truth is a constant file that multiple callers can import.
Returns True → caller should fall through to the full LangChain
tool path (Visual_Context_Camera / parse_visual_context becomes
reachable). Returns False → keep the draft's standby reply.
"""
try:
from core.constants import prompt_needs_vision
return prompt_needs_vision(prompt or '')
except Exception:
return False
def _draft_delegates(result: dict) -> bool:
"""The draft's own envelope says a bigger model must take this turn.
ONE reader for that field: escalation_reasons.draft_delegates, in the
module that already names delegate='local'/'hive' the baseline
escalation (CLASSIFIER_DELEGATE). is_casual has NO vote here. The
two fields are emitted independently and can contradict, and when
they did, the casual verdict won. MEASURED 2026-09-22 08:52:39
(speculation c77082b0-af7): delegate='local' AND is_casual=true on
"great open a notepad and type hi in it" -> the branch below was
skipped -> the draft's "I've opened a notepad for you and typed 'hi'
inside" shipped as the final answer and was spoken by TTS. No tool
ran; Computer_Action was in the is_first set that turn would have
loaded. Believing is_casual ships a tool-less model's description of
work as if the work had happened; believing delegate costs one slower
turn. See tests/unit/test_draft_delegate_is_not_vetoed_by_casual.py.
"""
from integrations.agent_engine.escalation_reasons import draft_delegates
return draft_delegates(result.get('delegate'))
def _chat_reply(user_id, request_id, response_text: str, **payload):
"""Return a /chat JSON response AND fire TTS synthesis in the background.
Every reply path from the /chat handler must go through this helper
so the TTS synthesis trigger lives in ONE place:
- Reuse path (chat_agent from reuse_recipe)
- Autonomous creation from reuse
- Creation suggested from reuse
- Agentic_Router tool fired
- Create_Agent tool fired (autonomous)
- Create_Agent tool fired (interactive)
- Normal LangChain get_ans path
- Evaluation mode after creation
Before this helper existed, only the normal LangChain path fired
``_tts_synthesize_and_publish`` inline (hart_intelligence_entry.py
line 6095), so every other reply route delivered text-only to the
frontend and the user saw no audio — the exact symptom reported
on 2026-04-11 ("hi got text reply but no TTS"). Consolidating here
makes it impossible for a new reply branch to accidentally skip
TTS: you can't return from /chat without calling this helper
(the old jsonify was renamed at each return site to match).
The TTS call is fire-and-forget — it submits work to the
background ``_tts_executor`` and returns immediately. No hot-path
latency is added; the frontend already subscribes to
com.hertzai.pupit.{userId} and plays whatever audio lands there.
Args:
user_id: User id for pupit topic routing.
request_id: Request id for correlating TTS audio to the chat
reply on the frontend.
response_text: The assistant's reply text. Empty strings skip
the TTS call (the old line 6096 else-branch still logs
the empty-response error for observability).
**payload: Extra keys to merge into the JSON response body.
The caller's previous jsonify arguments become kwargs.
Returns:
A Flask ``Response`` from ``jsonify(...)`` with the merged
payload — semantically identical to the old inline
``return jsonify({'response': response_text, **payload})``.
"""
# An elided-text pointer a model copied into its answer is never shown
# to the user, spoken or stored as the reply (owner ruling 2026-09-27;
# core.llm_outbound_logger.strip_elided_pointers).
from core.llm_outbound_logger import strip_elided_pointers
response_text = strip_elided_pointers(response_text)
# Where this reply came from (local | hive | cloud): ONE rule, named once
# per reply, on the /chat JSON below, HARTOS's own reply API (Nunba's
# route rebuilds its reply and still writes its own 'local' there, its
# follow-up). Local import: _chat_reply's source is exec'd in an
# isolated namespace by tests.
from core.constants import canonical_served_by
_served_by = canonical_served_by(payload.get('served_by'))
# A reel this turn made rides its reply as `media` (Reel Studio, plan
# 4.2(f)): taken once from the user file store's outbox, which marks it
# replied. Local imports, like the rest of this function's, and never
# the reason a reply fails.
_media = payload.get('media')
if not _media:
try:
from flask import has_request_context as _media_hrc
from flask import request as _media_req
_media_pid = payload.get('prompt_id') or (
(_media_req.get_json(silent=True) or {}).get('prompt_id')
if _media_hrc() else None)
if _media_pid:
from core.chat_attachments import take_outbound
_media = take_outbound(user_id, _media_pid, request_id)
except Exception as _media_err:
app.logger.warning(f"_chat_reply: reply media skipped: {_media_err}")
if _media:
payload['media'] = _media
if response_text:
# media_mode honor: the Nunba adapter has forwarded the user's
# chosen mode ('audio'|'video'|'text') in the /chat body all
# along (hartos_backend_adapter.py carried a TODO naming this
# exact read), but nothing here consumed it — every reply was
# synthesized, so text-mode users were spoken at (measured
# 2026-08-30 17:48, #731). Only an EXPLICIT 'text' suppresses;
# absent means the caller predates the flag and keeps today's
# behavior. Callers with no request context (the speculative
# expert publish calls _tts_synthesize_and_publish directly,
# below this gate) are untouched.
_tts_wanted = True
_avatar_id = None
try:
# Local import: _chat_reply's source is exec'd in an isolated
# namespace by test_consent_fanout_p2, where module globals
# (including the flask request proxy) do not exist.
from flask import request as _req
_body = _req.get_json(silent=True) or {}
_mm = _body.get('media_mode')
if _mm == 'text':
_tts_wanted = False
app.logger.info(
'_chat_reply: TTS suppressed (media_mode=text) for '
f'request_id={request_id}')
# The avatar this reply is spoken as. Its voice belongs to the
# avatar id, like its image (core/teacher_avatar.py), never to the
# agent: one avatar fronts many agents, and one agent can speak as
# several avatars. Absent -> the engine's default voice.
try:
from core.teacher_avatar import avatar_id_from
_avatar_id = avatar_id_from(_body.get('teacher_avatar_id'))
except Exception as _ae:
app.logger.debug(f"_chat_reply: avatar id unread: {_ae}")
except RuntimeError:
pass # outside a request — keep speaking, as before
# A background goal turn ('daemon_' request id) has no listener:
# its audio is published to the agent's own user id, which no client
# holds. Measured 2026-09-30..10-01: 37 syntheses, all targeted=0,
# each ~30 s of the same Piper engine a real reply needs. Only a
# NON-EMPTY id that is not a genuine user's is silenced; an empty id
# keeps speaking (callers that predate request ids), so the one
# discriminator is dispatch.is_genuine_user_request, never a prefix
# test here.
try:
from integrations.agent_engine.dispatch import (
is_genuine_user_request as _is_user_rid)
if request_id and not _is_user_rid(request_id):
_tts_wanted = False
app.logger.info(
'_chat_reply: TTS suppressed (background turn) for '
f'request_id={request_id}')
except Exception as _dm_err:
app.logger.debug(f"_chat_reply: daemon-turn gate skipped: {_dm_err}")
# preferred_lang resolution must match the chat entry path:
# body/kwarg → canonical persisted reader → 'en'. Bare
# 'en' default forced English Piper on Tamil replies. Resolved
# ahead of the TTS gate because the chat-sync persist below reads it
# on EVERY reply: inside the gate, a text-mode reply left it unbound
# there, and the UnboundLocalError dropped that turn from the
# cross-device mirror.
_lang = payload.get('preferred_lang') or payload.get('language')
if not _lang:
try:
from core.user_lang import get_preferred_lang
_lang = get_preferred_lang() or 'en'
except Exception:
_lang = 'en'
if _tts_wanted:
try:
_tts_synthesize_and_publish(response_text, user_id, request_id,
language=_lang, avatar_id=_avatar_id)
except Exception as e:
# Never let a TTS failure block delivery of the text reply.
app.logger.debug(f"_chat_reply: TTS dispatch skipped: {e}")
# Persist conversation turn to SimpleMem so multi-turn context works.
# prep_outputs (the normal LangChain path) never fires for casual_conv
# or draft-first early returns, so we save here — the ONE place every
# reply goes through. The user prompt comes via the 'user_prompt' kwarg
# from callers that have it (most /chat paths do).
_prompt = payload.pop('user_prompt', '')
try:
if _prompt and response_text:
_mem = get_memory(user_id=user_id)
if _mem and hasattr(_mem, 'save_context'):
from langchain_classic.schema.messages import HumanMessage, AIMessage
_mem.save_context(
{'input': _prompt},
{'output': response_text[:500]},
)
except Exception as _mem_err:
app.logger.debug(f"_chat_reply: memory save skipped: {_mem_err}")
# Index the turn into the MemoryGraph too — the OTHER half of
# prep_outputs' job (hie:6173). This block saved early-return
# turns to SimpleMem only, so a fact stated on a draft-first /
# casual turn was buffered but invisible to recall_memory's
# store search (measured 2026-08-30: "my favorite color is teal"
# in buffer.json, absent from the recall results, #729). Same
# canonical primitives as prep_outputs; the flask.g flag —
# stamped there — stops executor turns being indexed twice.
try:
if _prompt and response_text:
from flask import g as _g, has_request_context as _hrc
_already = _hrc() and getattr(_g, '_hie_graph_indexed', False)
if not _already:
_prom_id = payload.get('prompt_id')
_graph = _get_or_create_graph(user_id, _prom_id)
if _graph:
_sk = (f"{user_id}_{_prom_id}" if _prom_id
else str(user_id))
def _bg_reg(g=_graph, ui=_prompt,
ao=response_text, sk=_sk):
try:
g.register_conversation('user', ui, sk)
g.register_conversation('langchain', ao, sk)
except Exception:
logging.getLogger(__name__).exception(
'_chat_reply graph register: swallowed')
threading.Thread(target=_bg_reg, daemon=True).start()
except Exception as _gr_err:
app.logger.debug(f"_chat_reply: graph register skipped: {_gr_err}")
# U1-U8 cross-device mirroring (task #389): submit persist + WAMP
# publish for both sides of the turn to the chat_messages
# executor — the chat response is already serialized and on its
# way to the client; the DB write and chat.new fan-out must not
# add latency to this path. On worker saturation we fall back
# to inline (see persist_and_publish_async). request_id links
# the (user, assistant) pair for downstream dedup. device_id /
# agent_id / lang / attachments are optional; callers pass them
# via **payload when known.
try:
from integrations.social import chat_messages as _cm
# Order of precedence for the turn's fields: explicit payload
# kwarg -> /chat request body -> default. device_id and
# prompt_id are passed by no caller, so the body is what names
# the device a turn came from (a client drops its own turns by
# it) and the agent it was for.
_req_body = {}
try:
from flask import has_request_context
from flask import request as _flask_req
if has_request_context():
_req_body = _flask_req.get_json(silent=True) or {}
except Exception:
_req_body = {}
_dev = payload.get('device_id') or _req_body.get('device_id')
_agent = payload.get('agent_id')
_prompt_id = payload.get('prompt_id') or _req_body.get('prompt_id')
_lang_hint = (payload.get('preferred_lang')
or payload.get('language') or _lang)
_atts = payload.get('attachments')
_rid = str(request_id) if request_id is not None else None
# P2-S4 (2026-05-26): thread channel_type through to
# chat_messages.persist so interview / external-room Q&A
# land in a separate logical thread from regular chat
# without forking the persist pipeline. Default 'chat'
# preserves existing behaviour for every other caller.
_chan_type = (payload.get('channel_type')
or _req_body.get('channel_type') or 'chat')
if _prompt:
_cm.persist_and_publish_async(
str(user_id), 'user', _prompt,
agent_id=_agent, prompt_id=_prompt_id,
request_id=_rid, device_id=_dev,
lang=_lang_hint, attachments=_atts,
channel_type=_chan_type,
)
# The reply's media (a reel) in the mirror's documented
# attachments shape; none for any other reply, as before.
_reply_atts = None
if _media:
from core.chat_attachments import media_to_mirror
_reply_atts = media_to_mirror(_media) or None
_cm.persist_and_publish_async(
str(user_id), 'assistant', response_text,
agent_id=_agent, prompt_id=_prompt_id,
request_id=_rid, device_id=_dev,
lang=_lang_hint, attachments=_reply_atts,
channel_type=_chan_type,
)
except Exception as _chat_sync_err:
app.logger.debug(
f"_chat_reply: chat-sync mirror skipped: {_chat_sync_err}")
payload['served_by'] = _served_by
payload['response'] = response_text
return jsonify(payload)
def _offer_voice_clone_setup(language):
"""This turn wanted a recorded voice and spoke in the default one: offer
to set a cloning engine up, on the demand of the turn that needed it.
The offer is the canonical consent card (capability_setup.
offer_voice_clone_setup -> ConsentService), and the owner's Allow starts
the provisioning goal the agent daemon paces. Nothing here waits and
nothing here can fail the reply: the audio is already on its way in the
default voice, and an offer that cannot be made is a debug line.
"""
try:
from integrations.agent_engine.capability_setup import (
offer_voice_clone_setup,
)
outcome = offer_voice_clone_setup(language)
if outcome in ('asked', 'provisioning'):
app.logger.info(
f"TTS: no cloning engine here; voice-clone setup {outcome}")
except Exception as e:
app.logger.debug(f"TTS: voice-clone setup offer skipped: {e}")
def _speak_in_voice(text, language, voice):
"""Speak ``text`` in the recorded ``voice``; returns the audio path, or
None for the default voice.
Cloning from a reference is the canonical synth entry's job:
TTSRouter.synthesize requires a cloning engine when it is given a voice
and brings one up on demand, while tts_engine.synthesize_text -- the
chat path's default voice -- is the divergent copy
(docs/architecture/HARTOS_PARALLEL_PATH_AUDIT.md "TTS orchestration
split", docs/internal/ux_degrading_design_choices 3.5). So only a
voiced utterance goes to the router, and synthesize_text never receives
a reference: its primary backend (Piper on a desktop) treats one as a
voice id and raises, and its fallback then makes another engine the
active one for good.
None unless ``voice`` is a reference by the router's own test
(tts_router.synthesize: ``voice not in ('default', '', None)``), and
unless a cloning engine answered with a file: the router appends espeak
even when it needs a clone, and espeak ignores the voice, so an answer
without an error does not prove the voice was used. (Moving that check
into the router changes /api/voice/speak's contract; it is tracked
separately.)
"""
if voice in (None, '', 'default'):
return None
try:
from core.tool_logging import timed_stage
from integrations.channels.media.tts_router import (
ENGINE_REGISTRY, get_tts_router,
)
with timed_stage('tts.synthesize_voiced', logger=app.logger,
warn_over_ms=3000, chars=len(text), lang=language):
result = get_tts_router().synthesize(
text, language=language, voice=voice, source='chat_response')
except Exception as e:
app.logger.warning(f"TTS: voiced synthesis failed ({e}); default voice")
return None
if result.error or not result.path or not os.path.isfile(result.path):
app.logger.info(f"TTS: voiced synthesis gave no audio "
f"({result.error or result.path!r}); default voice")
_offer_voice_clone_setup(language)
return None
spec = ENGINE_REGISTRY.get(result.engine_id)
if spec is None or not spec.voice_clone:
app.logger.info(f"TTS: {result.engine_id} cannot clone a voice; "
f"default voice")
_offer_voice_clone_setup(language)
return None
# Served by Nunba's /tts/audio/: a cloning engine's ToolWorker
# writes under HEVOLVE_MODEL_DIR (default ~/.hevolve/models)//output,
# and that route searches ~/.hevolve/models/*/output. So this holds while
# HEVOLVE_MODEL_DIR stays at its default, as for Nunba's own GPU engines.
return result.path
def _tts_synthesize_and_publish(text, user_id, request_id, language=None,
avatar_id=None):
"""Fire-and-forget: synthesize TTS, push audio via WAMP.
Same pattern as chatbot_pipeline/chatbot.py:
make_it_talk(text) → publish(res, user_id, 'pupit')
Frontend subscribes to com.hertzai.pupit.{userId} and plays.
Calls the existing synthesize_text() which handles everything:
language segmentation, multi-lang routing, / tags,
model orchestration, GPU swap — the full pipeline.
Only fires when TTS engine exists (Nunba bundled mode).
``language`` None means the caller does not know the turn's language
(the speculative expert reply): the user's persisted preference is
spoken, the same reader _chat_reply falls back to, not English.
``avatar_id`` is the avatar this utterance is spoken as. Its recorded
voice (core/teacher_avatar.voice_reference) is spoken through the
canonical synth entry, TTSRouter.synthesize, which clones from it (see
_speak_in_voice). None, an avatar with no voice, or a voice no engine
here can clone keeps synthesize_text's default voice.
"""
if not text or not text.strip():
app.logger.debug("TTS: skipped (empty text)")
return
if not language:
try:
from core.user_lang import get_preferred_lang
language = get_preferred_lang() or 'en'
except Exception:
language = 'en'
try:
from tts.tts_engine import get_tts_engine
engine = get_tts_engine()
if not engine:
app.logger.warning("TTS: skipped (engine is None)")
return
app.logger.info(f"TTS: engine OK ({engine.backend_name}), submitting for '{text[:50]}...'")
except ImportError as ie:
app.logger.warning(f"TTS: skipped (ImportError: {ie})")
return
def _bg():
app.logger.info(f"TTS _bg: thread started for request_id={request_id}")
try:
from tts.tts_engine import synthesize_text
# Clean text for speech — remove artifacts that TTS can't pronounce
import re as _re
_clean = text
_clean = _re.sub(r'```[\s\S]*?```', '', _clean) # code blocks
_clean = _re.sub(r'`[^`]+`', '', _clean) # inline code
_clean = _re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', _clean) # [text](url) → text
_clean = _re.sub(r'https?://\S+', '', _clean) # URLs
_clean = _re.sub(r'[*_~]{1,3}', '', _clean) # markdown bold/italic
_clean = _re.sub(r'#{1,6}\s', '', _clean) # markdown headers
_clean = _re.sub(r'\[.*?\]', '', _clean) # [bracketed content]
_clean = _re.sub(r"['\"]", '', _clean) # quotes that cause artifacts
# Remove emojis (Unicode emoji ranges)
_clean = _re.sub(
r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF'
r'\U0001F1E0-\U0001F1FF\U00002702-\U000027B0\U0001F900-\U0001F9FF'
r'\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF\U00002600-\U000026FF'
r'\U0000FE00-\U0000FE0F\U0000200D]+', '', _clean)
_clean = _re.sub(r'\s+', ' ', _clean).strip() # collapse whitespace
if not _clean:
return # nothing left after cleaning
# The avatar's recorded voice, by the lookup a generated video uses
# (core/teacher_avatar.py). Resolved here on the TTS worker, never
# on the request thread, so the database round-trip (loopback on a
# desktop) and a first download never delay the text reply.
_voice = None
if avatar_id is not None:
try:
from core.teacher_avatar import voice_reference
_voice = voice_reference(avatar_id)
except Exception as _ve:
app.logger.debug(
f"TTS: voice of avatar {avatar_id} skipped: {_ve}")
# A voiced utterance goes through the canonical synth entry, which
# normalizes the text itself, so the stripped text goes in. None
# -> the default voice below.
audio_path = _speak_in_voice(_clean, language, _voice)
# ── Converge on the ONE normalizer (task #10 / 3.5) ──
# The stripping above removes artifacts TTS cannot SAY. It does
# NOT turn "Rs.200", "12.5%" or "2:30 PM" into words — that is
# tts_text_normalizer.normalize_for_tts, and until now the ONLY
# caller was tts_router.synthesize. This path (chat reply -> WAMP
# audio) goes straight to tts_engine.synthesize_text and skipped
# it entirely, so the very same sentence was pronounced correctly
# through /api/voice/speak and spoken as garbage in chat. The
# normalizer's own docstring asserts "Single converging path:
# called ONCE from tts_router.synthesize() ... we do not duplicate
# this logic per-engine" — that was true of the ENGINES and false
# of the CALLERS. This makes it true of both.
#
# use_llm is DERIVED from the canonical SOURCE_URGENCY table, not
# hardcoded: retuning 'chat_response' there retunes this caller
# too. Importing tts_router costs only its own module here — its
# top-level imports are stdlib and `integrations` is long since
# loaded in this process.
#
# STILL DIVERGENT, deliberately: URLs are DELETED above, whereas
# the router path EXPANDS them to spoken form. Reading a URL aloud
# after a chat reply is not obviously wanted, and changing it
# changes what the user hears — that belongs with the engine
# unification half of 3.5, which needs a listening test.
# timed_stage (core.tool_logging): this span was UNATTRIBUTABLE.
# Measured 2026-08-12: 33.4s and 46.0s elapsed between "TTS _bg:
# thread started" and "Synthesized audio", while Piper synthesizes
# the SAME text standalone in 0.38s (RTF 0.045-0.089). The only
# expensive candidate in the span was this normalization — which
# logged NOTHING on success and only DEBUG on failure, so ~45s of
# user-visible delay had no owner in the logs. Both stages are now
# timed with the canonical latency_ms= key.
#
# use_llm is True here for chat_response (SOURCE_URGENCY says
# 'normal', and tts_router.llm_rewrite_allowed allows the rewrite
# for it), so this stage can
# make an LLM round-trip on the SAME contended server that just
# served the reply. budget: the docstring's "never let
# normalization block speech" promise is about crashes; the
# warn_over_ms below makes it also true of LATENCY, which is the
# failure mode that actually bit us.
if not audio_path:
try:
from core.tool_logging import timed_stage
from integrations.channels.media.tts_router import (
SOURCE_URGENCY, llm_rewrite_allowed,
)
from integrations.channels.media.tts_text_normalizer import (
normalize_for_tts,
)
_urgency = SOURCE_URGENCY.get('chat_response', 'normal')
_use_llm = llm_rewrite_allowed(_urgency)
with timed_stage('tts.normalize', logger=app.logger,
warn_over_ms=1500, use_llm=_use_llm,
chars=len(_clean), lang=language):
_clean = normalize_for_tts(
_clean, language, use_llm=_use_llm,
)
except Exception as _ne: # never let normalization block speech
app.logger.debug(f"TTS: normalization skipped ({_ne})")
# The default voice: synthesize_text never receives a voice
# reference (_speak_in_voice says why).
from core.tool_logging import timed_stage as _timed_stage
with _timed_stage('tts.synthesize', logger=app.logger,
warn_over_ms=3000, chars=len(_clean),
lang=language):
_raw = synthesize_text(_clean, language=language)
app.logger.info(f"TTS async: synthesize_text returned: {_raw}")
# synthesize_text may return a file path string OR a JSON dict/string
# with {"path": "...", "duration": ...}. Normalize to a file path.
audio_path = _raw
if isinstance(_raw, dict):
audio_path = _raw.get('path', '')
elif isinstance(_raw, str) and _raw.startswith('{'):
try:
audio_path = json.loads(_raw).get('path', '')
except (json.JSONDecodeError, AttributeError):
logging.getLogger(__name__).debug("_bg: swallowed json.JSONDecodeError, AttributeError", exc_info=True)
if audio_path and os.path.isfile(audio_path):
audio_filename = os.path.basename(audio_path)
# Use absolute URL if this node's external URL is known —
# required when the request was delegated from a remote peer,
# since relative URLs resolve against the originator's server
# which doesn't have this audio file (task #266).
_ext_url = os.environ.get('HEVOLVE_EXTERNAL_URL', '').rstrip('/')
if _ext_url:
audio_url = f'{_ext_url}/tts/audio/{audio_filename}'
else:
audio_url = f'/tts/audio/{audio_filename}'
app.logger.info(f"TTS async: publishing audio {audio_url} to pupit.{user_id}")
# A phone reads the bundle shape central's lip-sync service
# publishes (Android VideoUrl: aud_url + face data); with
# aud_url alone it plays the voice over the idle avatar
# (HARTOS #111, owner: audio only for now, every later
# capability on the same delivery). Additive beside the
# SPA's generated_audio_url. The url is absolute on the
# address this node advertises: a phone gets the bundle over
# its device link and has no origin to resolve a path
# against, while the SPA keeps the relative url (its own
# origin is the loopback the LAN gate trusts).
try:
from core.port_registry import get_advertisable_base_url
_phone_audio_url = (audio_url if _ext_url
else f'{get_advertisable_base_url().rstrip("/")}{audio_url}')
except Exception as e:
app.logger.warning(
f"TTS async: no advertisable base url for the phone's "
f"bundle ({e}); sending {audio_url} as is")
_phone_audio_url = audio_url
_tts_payload = {
'text': [text[:200]],
'generated_audio_url': audio_url,
'request_id': str(request_id),
'action': 'TTS',
'video_link': {
'aud_url': _phone_audio_url,
'request_id': str(request_id),
'action': 'TTS',
'is_cartoon': True,
'priority': 0,
},
}
publish_async(f'com.hertzai.pupit.{user_id}', json.dumps(_tts_payload))
# Also push via SSE directly — publish_async → MessageBus/WAMP doesn't
# reach SSE clients, so the unified helper fans out to the Nunba
# main.py SSE broker. See core/platform/events.broadcast_sse_safe.
# NOTE: publish_async's generic SSE fan-out at line ~2014 SKIPS
# action='TTS' specifically because routing TTS through both
# paths produced double-audio (the SPA's request_id-keyed dedup
# missed cross-transport duplicates). TTS owns this explicit
# call; everything else (thinking traces, channel events) is
# auto-handled inside publish_async.
from core.platform.events import broadcast_sse_safe
if not broadcast_sse_safe('message', _tts_payload, user_id=user_id):
app.logger.debug("TTS SSE: broadcast_sse_event unavailable (Nunba main not loaded)")
app.logger.info(f"TTS async: published successfully")
else:
app.logger.warning(f"TTS async: no audio file — path={audio_path}, exists={os.path.isfile(audio_path) if audio_path else False}")
except ImportError:
logging.getLogger(__name__).debug("_bg: swallowed ImportError") # TTS not available (cloud mode)
except Exception as e:
app.logger.error(f"TTS async failed: {e}", exc_info=True)
global _tts_executor
try:
_tts_executor.submit(_bg)
except RuntimeError:
# Executor was shut down (interpreter restart, previous crash).
# Recreate and retry — TTS must not silently stop working.
app.logger.warning("TTS executor dead — recreating")
_tts_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='tts_async')
_tts_executor.submit(_bg)
# Foreground marker — SINGLE SOURCE is core.foreground.mark_view; the bundled
# Nunba chat_route applies the same decorator so there is one foreground rule,
# not a per-app copy. Degrade to a no-op if (somehow) unavailable so the route
# always loads.
try:
from core.foreground import mark_view as _mark_foreground
except Exception:
def _mark_foreground(fn):
return fn
# Gate the foreground marker on the SAME discriminator the outbound monkeypatch
# uses (dispatch.is_genuine_user_request). The daemon reuses /chat for its own
# autonomous goal dispatches (request_id 'daemon_*', carried in the
# X-HARTOS-Request-ID header or the JSON body); without this gate those tripped
# the 0->1 foreground edge, so the daemon yielded to ITSELF and a real user turn
# was never the edge that fires the abort of in-flight background 4B calls — the
# user's "hi" was starved (2026-06-13 dig). Registered once; covers BOTH the
# HARTOS /chat and the bundled Nunba chat_route (both apply mark_view). Fails
# open to "user" so a genuine turn is never accidentally de-prioritised.
def _chat_request_is_genuine():
try:
from flask import request as _rq
# Read request_id from EVERY transport a HART client uses, in priority
# order. The previous header+JSON-only read missed it whenever the
# client put it elsewhere: the Nunba desktop omitted it entirely (now
# sent in the JSON body) and the Android client sends it as a multipart
# FORM field (OkHttp MultipartBody.Part 'request_id'), which
# get_json(silent=True) returns None for → empty → the user turn was
# misclassified as BACKGROUND, so enter_foreground never fired and the
# daemon never yielded the llama slot (the "hi" starvation).
_rid = (_rq.headers.get('X-HARTOS-Request-ID')
or _rq.values.get('request_id') # form (multipart/urlencoded) + query
or (_rq.get_json(silent=True) or {}).get('request_id'))
except Exception:
return True # no request context / unparsable -> treat as a user turn
try:
# Delegate ENTIRELY to the one canonical discriminator — no bespoke,
# caller-specific "is user" rule here. is_genuine_user_request is the
# single source of truth for empty / 'daemon_' / real-id across every
# caller (this inbound mark_view AND the outbound abort gate), so they
# can never diverge. Empty → background is safe inbound too: a real
# /chat always carries an id (the frontend sends one; the Nunba adapter
# defaults a timestamp when omitted), so an inbound turn is never empty.
from integrations.agent_engine.dispatch import is_genuine_user_request
return is_genuine_user_request(_rid)
except Exception:
return True
try:
from core.foreground import set_genuine_check as _set_genuine_check
_set_genuine_check(_chat_request_is_genuine)
except Exception:
logging.getLogger(__name__).exception(": swallowed Exception")
@app.errorhandler(ImportError)
def _optional_capability_missing(exc):
"""An absent OPTIONAL dependency is a known state, not a server fault.
Real HW, 2026-08-12: /chat returned a bare 500 whose only readable cause was
buried in the journal --
autogen.AssistantAgent
ModuleNotFoundError: No module named 'autogen'
ImportError: Agent creation requires the 'pyautogen' package.
gather_agentdetails already raises that ImportError with a perfectly good
message (deliberately, so the cause is stated where it is still known), but
nothing turned it into a RESPONSE, so Flask produced a generic 500 and the
desktop agent cards just said "retry connecting" -- forever, for a condition
that will never resolve by retrying. The diagnosis existed and was thrown away
one layer above the user. That is the gulped-error pattern this codebase is
supposed to refuse.
pyautogen is not in nixpkgs (nor are flaml/diskcache/tiktoken beneath it), so
on a Nix-built image the package is genuinely absent and the honest answer is
to SAY so, with the actual missing dependency and a 503 (capability
unavailable) rather than a 500 (we broke). 503 also tells the client this is
not worth retrying on a timer, which is exactly what the cards were doing.
Scoped to ImportError only: every other exception keeps its existing
behaviour, and a route that already handles ImportError itself still wins,
because Flask only consults this handler for UNHANDLED exceptions.
"""
msg = str(exc) or 'an optional capability is not installed'
missing = None
for _pkg in ('pyautogen', 'autogen'):
if _pkg in msg:
missing = _pkg
break
app.logger.warning('optional capability unavailable on this build: %s', msg)
return jsonify({
'error': msg,
'reason': 'optional_capability_missing',
'missing_package': missing,
'retryable': False,
'response': None,
}), 503
_EMPTY_BUILD_REPLY = (
"I have the name and goal, but not every step came through as a step I "
"can build. Tell me the actions this agent should take, in order, and I "
"will build it from those."
)
def _config_is_buildable(cfg) -> bool:
"""True iff a 'completed' agent config actually contains actions to build.
LIVE-PROVEN 2026-08-29 20:00 (prompt_id 88013682884, driven through the
real /chat API): the model answered gather_info with status='completed'
but every STRUCTURAL field empty -- personas '', tools '', flows
[{'flow_name': '', 'persona': '', 'actions': [], 'sub_goal': ''}] -- while
filling in name, goal and a full personality block. Log evidence: zero
'gather_info parse error', zero 'salvaging partial', one 'COMPLETED
STATUS'. So the parse was faithful and this was the NORMAL path; the
model simply returned an empty shell and nothing checked that
"completed" meant buildable. It was saved with is_active=true and can
never produce a flow recipe, because there are no actions to execute.
That shape is 485 of the 624-config corpus (#718).
Every action must be readable as one, too: text, or the {'action': text}
dict saved configs also carry. LIVE 2026-10-08 18:47 (prompt_id 54): the
person confirmed a review of three actions and the model's final JSON held
two of them and the flow's sub_goal as an object in their place; saved as
is, the confirmed third step was gone and the flow carried an entry no
step can be built from.
"""
def _is_action(action):
if isinstance(action, dict):
action = action.get('action')
return isinstance(action, str) and bool(action.strip())
try:
flows = [f for f in ((cfg or {}).get('flows') or [])
if isinstance(f, dict)]
listed = [f.get('actions') for f in flows if f.get('actions')]
return bool(listed) and all(
isinstance(actions, list) and all(_is_action(a) for a in actions)
for actions in listed)
except (AttributeError, TypeError):
return False
#: The list fields of an agent config. gather_info's own completed template
#: shows personas and tools as strings, and the model sometimes writes them,
#: and flows, as JSON text of the list it means.
_CONFIG_LIST_FIELDS = ('personas', 'tools', 'flows')
def _decode_config_lists(cfg):
"""The config with each list field the model wrote as JSON text decoded,
and the names of the fields decoded.
LIVE 2026-10-08 20:39:45 (prompt_id 54): the confirmed config came back
with personas, tools and flows each as the JSON text of the right list
(one flow, three plain-text actions, the sub_goal in place). Left as
text, flows holds no flow and the config is refused as unbuildable, and a
personas string breaks every reader that indexes it. Only text that
parses as a JSON list is decoded: anything else stays as it came, for
_config_is_buildable to judge. The input is not changed.
"""
if not isinstance(cfg, dict):
return cfg, ()
out, decoded = dict(cfg), []
for key in _CONFIG_LIST_FIELDS:
value = out.get(key)
if not (isinstance(value, str) and value.strip().startswith('[')):
continue
try:
items = json.loads(value)
except ValueError:
continue
if isinstance(items, list):
out[key] = items
decoded.append(key)
return out, tuple(decoded)
#: What the gather model is told, in the same turn, when its completed config
#: cannot be built (#205). The steps are the ones the person confirmed, so the
#: model is asked to write them again before the person is asked to.
_BUILDABLE_REASK = (
'That configuration cannot be built: in "flows", every item of "actions" '
'must be one step written as plain text. Send the same configuration '
'again as one JSON object with "status": "completed" and the steps the '
'person confirmed, each step as its own string in "actions".')
def _parse_plan_reply(text):
"""A gather_info reply, parsed: the one parse every gather site uses.
A reply that is valid JSON is taken as the model wrote it. Anything
else is read as before: JSON's true/false capitalised for
helper.retrieve_json's literal fallback, then retrieve_json. That
rewrite is blind, so it also changed words inside the plan ("construe"
became "consTrue", "true or false" became "True or False").
Measured 2026-11-02 on the owner's desktop (review of a88258c98, F-e):
of 2,328 saved configs and staged plans written as JSON, the rewrite
then retrieve_json returned 1,922 exactly and 406 with words changed;
JSON first returns all 2,328. Written as Python dicts, with True/False
or with true/false, both parses read the same. Of 326 real replies in
the outbound LLM log (288 valid JSON), 324 parse the same and 2 keep
words the rewrite changed. The cost: a reply that is valid JSON keeps
its curly quotes and dashes, which retrieve_json flattens to ASCII.
The only place the rewrite remains; a source guard
(tests/unit/test_the_approved_plan_is_the_saved_plan.py) keeps it so."""
try:
return json.loads(text)
except (TypeError, ValueError):
return retrieve_json(text.replace('true', 'True').replace('false', 'False'))
def _read_gather_config(response, prompt_id):
"""The agent config in a gather_info reply, with its list fields decoded
(_decode_config_lists). Raises ValueError when the reply holds none.
Moved out of chat() unchanged, so the model's second answer (#205) is read
the same way as its first."""
# Detect context-exceeded errors before parsing
if 'Context size has been exceeded' in response:
raise ValueError('LLM context size exceeded — cannot parse gather_info response')
try:
new_res = _parse_plan_reply(response)
app.logger.info(f"new_res: {new_res}")
except Exception as e:
app.logger.error(f'Got some error while will try with re match error:{e}')
json_match = re.search(r'{[\s\S]*}', response)
if json_match:
new_res = json.loads(json_match.group(0))
else:
raise ValueError('No JSON in response')
# retrieve_json can return None without raising — catch it early
if new_res is None:
json_match = re.search(r'{[\s\S]*}', response)
if json_match:
new_res = json.loads(json_match.group(0))
else:
raise ValueError('retrieve_json returned None and no JSON found in response')
# LLM sometimes returns a list of conversation turns instead of a single dict
if isinstance(new_res, list):
app.logger.info(f'new_res is a list (len={len(new_res)}), extracting last dict with status')
for item in reversed(new_res):
if isinstance(item, dict) and 'status' in item:
new_res = item
break
else:
for item in reversed(new_res):
if isinstance(item, dict):
new_res = item
break
else:
raise ValueError(f'List response has no usable dict: {new_res}')
app.logger.info(f'Extracted dict: status={new_res.get("status")}')
if new_res is None:
raise ValueError('new_res is None after parsing')
return _decoded_config(new_res, prompt_id)
def _decoded_config(cfg, prompt_id):
"""``cfg`` with its list fields decoded (_decode_config_lists), and the
decoding logged. The one reading of a config's lists, for the
interactive reply (_read_gather_config) and the autonomous save
(_buildable_completed_config)."""
cfg, decoded = _decode_config_lists(cfg)
if decoded:
app.logger.info(
"[GATHER] %s came as JSON text for %s - decoded",
', '.join(decoded), prompt_id)
return cfg
def _buildable_completed_config(cfg, prompt_id):
"""``cfg`` ready to save as a built agent, or None.
The autonomous creation's save rule, the same as the interactive one
(#205/#718): list fields written as JSON text are decoded, and a
'completed' config that is not _config_is_buildable is not saved as
built (#206). F2 of the cdeca7500 review (ran by its reviewer,
2026-11-02): once _autonomous_gather_info finalized an approved plan in
code instead of asking the model to re-emit it, a plan with no `flows`
reached the save as 'completed', and recipe() fails on config['flows']
(hartos/create_recipe.py:1097, :7131). None for anything that is not a
'completed' config, so the caller treats it as not finished."""
if not isinstance(cfg, dict) or (cfg.get('status') or '').lower() != 'completed':
return None
config = _decoded_config(cfg, prompt_id)
if _config_is_buildable(config):
return config
app.logger.warning(
"[EMPTY-BUILD] autonomous creation of %s finished without a step for "
"every action (flows: %.300s) - not saved as built (#206)",
prompt_id, config.get('flows'))
return None
#: A step's own number, as the model sometimes writes it ("1. Call ...").
#: The space after it is required, so a step that begins with a decimal
#: ("1.5 litres of water ...") keeps its number.
_STEP_NUMBER = re.compile(r'^\s*\d+\s*[.)](?:\s+|$)')
def _step_text(action):
"""One action as the person reads it: its text (an action is text or
{'action': text}), spaces collapsed, without a number of its own, which
the review adds."""
text = action.get('action') if isinstance(action, dict) else action
return _STEP_NUMBER.sub('', ' '.join(str(text or '').split()))
def _config_steps(cfg):
"""Every flow's steps, in order, as _step_text reads them: what a review
of the config shows, and what a completed config is compared by before
it is built (#205). Only the steps, on purpose: they are what gets
built and what the person checks. A name, goal, flow name or persona
the model rewrites after the review does not show the config again."""
return tuple(tuple(_step_text(a) for a in (flow.get('actions') or []))
for flow in (cfg.get('flows') or [])
if isinstance(flow, dict))
def _config_review_text(cfg):
"""The config as the person reads it before it is built: its name, goal
and every flow's steps, numbered, and how to answer. Shown when the model
finished without showing a review, or with steps other than the ones
shown (#205), so what the person confirms is the config that is built.
Only called on a buildable config."""
lines = ['Before I build it, here is the agent as I have it:', '']
if cfg.get('name'):
lines.append(f"Name: {cfg['name']}")
if cfg.get('goal'):
lines.append(f"Goal: {cfg['goal']}")
for flow in cfg.get('flows') or []:
if not isinstance(flow, dict):
continue
title = flow.get('flow_name') or 'Steps'
lines += ['', f"{title} ({flow['persona']}):" if flow.get('persona')
else f"{title}:"]
for number, action in enumerate(flow.get('actions') or [], 1):
lines.append(f"{number}. {_step_text(action)}")
lines += ['', 'Reply yes to build it, or tell me what to change.']
return '\n'.join(lines)
def _is_plain_yes(text):
"""Whether the person answered a review with the one word it asks for:
"yes" on its own, in any case, with or without a full stop or an
exclamation mark after it. Nothing else counts - not "ok", not "y", not
"yes but ..." - so an answer that carries a change, or might, is never
read as a confirmation; every other answer goes to the model (#205).
No pattern matching, on purpose (owner, 2026-10-20)."""
return ' '.join(str(text or '').split()).rstrip('.!').lower() == 'yes'
@app.teardown_request
def _a_turns_model_ends_with_its_request(_exc=None):
"""A turn's model lasts as long as its request (#172). /chat sets the
thread's model_config override when a turn starts and nothing cleared it
when the turn ended, by any return, an exception, or the daemon's
in-process test client (dispatch._native_chat, on the daemon's own
thread). /time_agent and /visual_agent never set it, so a scheduled run
on a reused worker thread inherited an earlier expert turn's model and
spent it outside the day-cap check (review of 75dc20add, P2)."""
thread_local_data.clear_model_config_override()
@app.route('/chat', methods=['POST'])
@_mark_foreground
def chat():
# ── G6: Authentication gate ──
# Three-layer auth: API key > JWT > body user_id (backward compat)
# HEVOLVE_API_KEY env var: if set, all requests MUST provide it.
# If not set (dev mode), allow unauthenticated but log a warning.
_api_key = os.environ.get('HEVOLVE_API_KEY', '')
auth_header = request.headers.get('Authorization', '')
_bearer_token = auth_header[7:] if auth_header.startswith('Bearer ') else ''
if _api_key:
# API key is configured — validate it first (Layer 0)
# Accept either API key directly or a valid JWT
import hmac as _hmac_mod
# The key arrives as X-API-Key, the header security/middleware.py's
# gate accepts and dispatch._internal_auth_headers sends; before this,
# every metered self-POST on a keyed node (the PAID calls) was
# admitted by the middleware and then 401'd here (review of
# 7086adff8, finding 2). The Bearer form of the key only reaches
# here from a caller the middleware does not gate (a bundled
# desktop's own local caller): the gate decodes a Bearer as a JWT.
_x_api_key = request.headers.get('X-API-Key', '')
_api_key_match = (
(_bearer_token and _hmac_mod.compare_digest(_bearer_token, _api_key)) or
(_x_api_key and _hmac_mod.compare_digest(_x_api_key, _api_key))
)
if _api_key_match:
g.auth_source = 'api_key'
elif getattr(g, 'auth_source', None) == 'device':
# A phone the desktop owner allowed (#111): security.middleware
# verified its Ed25519-signed token against the key on file and
# left the payload in g.jwt_payload. The HS256 decode below
# cannot verify that token (the phone has no local secret), so
# the gate's verdict stands.
pass
elif _bearer_token:
# Try JWT decode (Layer 1 / Layer 2)
try:
from integrations.social.auth import decode_jwt
jwt_payload = decode_jwt(_bearer_token)
if jwt_payload and 'user_id' in jwt_payload:
g.auth_source = 'jwt'
else:
return jsonify({
'error': 'Invalid or expired token.',
'response': None,
}), 401
except Exception:
return jsonify({
'error': 'Invalid or expired token.',
'response': None,
}), 401
else:
return jsonify({
'error': 'Authentication required. Provide Authorization: Bearer header.',
'response': None,
}), 401
else:
# No API key configured (dev/flat mode) — log warning once
if not getattr(chat, '_auth_warned', False):
app.logger.warning(
'HEVOLVE_API_KEY not set — /chat endpoint accepts unauthenticated '
'requests. Set HEVOLVE_API_KEY env var for production.'
)
chat._auth_warned = True
if getattr(g, 'auth_source', None) != 'device':
g.auth_source = 'none'
# Rate limit: 30 req/min per user/IP
try:
from integrations.social.rate_limiter import _limiter
# Rate limit by IP always (prevents user_id rotation bypass).
# Authenticated user_id added as secondary key for per-user tracking.
from core.auth_local import client_key as _client_key
rate_user = _client_key()
if not _limiter.check(str(rate_user), 'chat', max_tokens=30, refill_rate=30 / 60):
return jsonify({'error': 'Rate limit exceeded (30/min). Please wait.', 'response': None}), 429
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError") # Rate limiter module not installed — allow (dev/flat mode)
except Exception as e:
# Rate limiter unavailable (Redis down, etc.) — fail closed on cloud
if os.environ.get('HEVOLVE_NODE_TIER') == 'central':
return jsonify({'error': 'Rate limiter unavailable — try again shortly', 'response': None}), 503
start_time = time.time()
# Periodically evict stale agent state to prevent unbounded memory growth (M2 fix)
_cleanup_stale_agents()
data = request.get_json() or {}
# Early validation — return 400 instead of 500 on missing required fields
if not data.get('user_id') and not request.headers.get('Authorization', '').startswith('Bearer '):
return jsonify({'error': 'user_id is required (in body or via Bearer token)', 'response': None}), 400
if data.get('prompt_id') is None and 'prompt_id' not in data:
return jsonify({'error': 'prompt_id is required', 'response': None}), 400
if not data.get('prompt') and not data.get('input_text'):
return jsonify({'error': 'prompt is required', 'response': None}), 400
# ── Two-layer auth: extract user_id from JWT if present ──
# Layer 1 (LOCAL): Bearer token signed by this node's HS256 secret
# Layer 2 (HIVE): Bearer token with Ed25519 node_sig (cross-node)
# Fallback: body user_id (backward compat for desktop/Nunba mode)
if g.auth_source == 'device':
# The user is the one the phone's verified token names; the gate
# already refused a body naming anyone else (#51). Fail closed: a
# device verdict without its payload admits nobody, never the
# body's or a default user.
_device_uid = (getattr(g, 'jwt_payload', None) or {}).get('user_id')
if not _device_uid:
return jsonify({'error': 'Invalid or expired token.', 'response': None}), 401
data['user_id'] = _device_uid
g.token_scope = 'hive'
elif g.auth_source not in ('jwt', 'api_key'):
if _bearer_token:
try:
from integrations.social.auth import decode_jwt
jwt_payload = decode_jwt(_bearer_token)
if jwt_payload and 'user_id' in jwt_payload:
_body_uid = data.get('user_id')
_jwt_uid = jwt_payload['user_id']
if _body_uid and str(_body_uid) != str(_jwt_uid):
# Sanitize for log injection (strip newlines/ANSI escapes)
import re as _re
_safe_body = _re.sub(r'[\x00-\x1f\x7f-\x9f]', '', str(_body_uid))[:64]
_safe_jwt = _re.sub(r'[\x00-\x1f\x7f-\x9f]', '', str(_jwt_uid))[:64]
app.logger.warning(
f'/chat user_id mismatch: body={_safe_body} jwt={_safe_jwt} '
f'— using JWT (body value dropped)')
data['user_id'] = _jwt_uid
g.auth_source = 'jwt'
g.token_scope = jwt_payload.get('scope', 'local')
else:
g.auth_source = 'body'
except Exception:
g.auth_source = 'body'
else:
g.auth_source = 'body' if g.auth_source == 'none' else g.auth_source
elif g.auth_source == 'jwt':
# JWT already decoded above in the API-key-present path
try:
from integrations.social.auth import decode_jwt
jwt_payload = decode_jwt(_bearer_token)
if jwt_payload and 'user_id' in jwt_payload:
data['user_id'] = jwt_payload['user_id']
g.token_scope = jwt_payload.get('scope', 'local')
except Exception:
logging.getLogger(__name__).exception("chat: swallowed Exception")
# Reject unauthenticated requests on multi-user deployments.
# Body-sourced user_id is only safe on flat tier (single-user desktop).
# On regional (multi-user LAN) or central (cloud), accepting body user_id
# lets any client impersonate any user.
_node_tier = os.environ.get('HEVOLVE_NODE_TIER', 'flat')
if g.auth_source == 'body' and (
_node_tier in ('central', 'regional') or
os.environ.get('HEVOLVE_REQUIRE_AUTH', '').lower() == 'true'
):
return jsonify({
'error': 'Authentication required. Provide Authorization: Bearer header.',
'response': None,
}), 401
user_id = data.get('user_id', None)
# Sanitize user_id globally (not just JWT-mismatch branch) to prevent
# log injection via newlines/ANSI escapes in body-sourced user_id.
if user_id is not None:
import re as _re
user_id = _re.sub(r'[\x00-\x1f\x7f-\x9f]', '', str(user_id))[:64]
data['user_id'] = user_id
# Reject oversized prompts to prevent DoS (worker-pool starvation).
_prompt = data.get('prompt') or data.get('input_text') or ''
if len(_prompt) > 16384:
return jsonify({
'error': f'Prompt too large ({len(_prompt)} chars, max 16384).',
'response': None,
}), 413
# Fall back to the canonical hart_language.json when frontend omits
# preferred_lang. Previously defaulted to 'en' which bypassed the
# draft skip-gate for Tamil (hart_language.json says 'ta' but /chat
# never read it). Single source of truth: core.user_lang.
_req_lang = data.get('preferred_lang') or data.get('language')
if _req_lang and _req_lang.strip():
preferred_lang = _req_lang.strip()
else:
try:
from core.user_lang import get_preferred_lang
preferred_lang = get_preferred_lang()
except Exception:
preferred_lang = 'en'
# Persist language preference so next startup warm-up loads the right
# TTS + right draft-boot decision (see llama_config.should_boot_draft
# cohort gate, commit 12c9304).
#
# PREVIOUS BUGGY LOGIC (removed 2026-04-15):
# if preferred_lang != 'en' and not _lang_persisted and not file_exists:
# _persist_language(...)
#
# The 3 guards caused "hart_language.json stuck at whatever was first
# written" — explicitly:
# - `!= 'en'` skipped English switches (user moves Tamil → English,
# nothing persists, next boot reads stale 'ta')
# - `not file_exists` meant after ANY write, subsequent switches
# no-op (user's Tamil selection never overwrote stale 'en' from
# first-run default)
# - `_lang_persisted` one-shot flag capped to one write per process
#
# `_persist_language` now read-first-skips-if-unchanged internally,
# so calling it every request is cheap (~10us stat call).
if preferred_lang:
_persist_language(preferred_lang)
# request_id may arrive as a multipart/urlencoded FORM field (Android OkHttp)
# rather than in the JSON body — read both so the thread-local id (and every
# downstream foreground/yield + log correlation) is never silently empty.
request_id = data.get('request_id') or request.values.get('request_id') or None
req_tool = data.get('tools', None)
# agent_tools False withholds Create_Agent and Agentic_Router from this
# turn (get_tools, core.constants.AGENT_WORK_TOOL_NAMES). The metered
# Hive API's self-POST sends it (task #120): its caller is a program that
# cannot answer an agent interview or a Plan Mode consent question, and
# the tool's output was the billed reply. Anyone may ask for fewer tools
# and nobody gets more, so the field needs no credential; absent, every
# person's turn keeps both. Read by the one boolean reader the env flags
# use (core.config_cache.flag_value), so 'FALSE', 'no' and 'off' decline
# too; junk keeps the default.
agent_tools = flag_value(data.get('agent_tools'), True)
file_id = data.get('file_id', None)
prompt_id = data.get('prompt_id', None)
# Stamp the thread-local HERE, the first line at which both values exist.
#
# WHY (measured live 2026-09-09 on agent 18088688973): the canonical stamp
# is ~1,100 lines below, and 25 of chat()'s returns fire in between --
# including all four exits of the agent-bound REUSE branch. So a reuse
# turn finished without ever writing the thread-local, and everything
# downstream that reads it saw None:
# * core/tool_logging.py could not name the session on any of 136 tool
# executions, leaving "did THIS agent's tool run?" unanswerable while
# four sessions interleaved in one log;
# * _emit_tool_call_stage publishes the per-tool UI status only when
# user_id is truthy -- 0 chat.stage events, i.e. task #509's feature
# is dead on this path, not merely unmeasured.
#
# The later stamp is deliberately LEFT IN PLACE and remains authoritative:
# the probe / intermediate / else arms below reset `prompt_id = 0`, and
# only that stamp records the 0. Hoisting instead of adding would put the
# real agent id into those paths' thread-local -- a regression.
# Guarded by tests/unit/test_chat_seeds_threadlocal_before_returning.py,
# which pins both the ordering and the survival of the later stamp.
thread_local_data.set_user_id(user_id=user_id)
thread_local_data.set_prompt_id(prompt_id)
# The turn's own answer to "may the model be offered agent work?", read
# by get_tools wherever it runs for this turn (get_ans's two calls and
# CustomGPT._call's prompt rebuild on later steps), so no caller of the
# turn can disagree with another.
thread_local_data.set_agent_tools(agent_tools)
# A Create_Agent or Agentic_Router flag left by an EARLIER turn on this
# reused worker thread (its turn ended before the handlers below get_ans
# read and cleared it) would route THIS turn into Creation Mode or Plan
# Mode: the handlers read the flags, never who set them (the review of
# 5a66a3308 read it; not measured live). Nothing sets either flag before
# get_ans within a turn (their only writers are the two tool handlers),
# so the turn starts clean.
thread_local_data.clear_creation_flags()
thread_local_data.clear_agentic_flags()
create_agent = data.get('create_agent', None)
casual_conv = data.get('casual_conv', False)
autonomous = data.get('autonomous', False)
probe = data.get('probe', None)
intermediate = data.get('intermediate', None)
# Tier ladder preference forwarded from Nunba Demopage toggle (see
# routes/hartos_backend_adapter.py + routes/chatbot_routes.py).
# Values: 'local_only' | 'auto' | 'hive_preferred'. Default 'auto'
# preserves today's behavior for every existing caller (old Nunba
# builds, direct HTTP clients, MCP bridge, tests). Validated on
# Nunba side too; we revalidate here to keep HARTOS self-contained.
intelligence_preference = data.get('intelligence_preference', 'auto')
if intelligence_preference not in ('local_only', 'auto', 'hive_preferred'):
intelligence_preference = 'auto'
# Draft-first dispatch: Qwen3.5-0.8B standby reply + delegate signal.
# Default ON — the dispatcher gracefully returns 'no_draft_model' when
# the 0.8B isn't loaded and we transparently fall through to the normal
# path, so enabling by default is safe on fresh installs. Request body
# or env var can still override:
# HEVOLVE_DRAFT_FIRST=0 → hard off (overrides request body)
# HEVOLVE_DRAFT_FIRST=1 → hard on (overrides request body)
# draft_first in body → per-request override
# (nothing) → default ON
_draft_first_env = os.environ.get('HEVOLVE_DRAFT_FIRST', '').strip()
if _draft_first_env == '0':
draft_first = False
elif _draft_first_env == '1':
draft_first = True
elif 'draft_first' in data:
draft_first = bool(data['draft_first'])
else:
draft_first = True
# Confidence floor for routing decisions driven by the draft
# classifier's intent flags (is_create_agent). Below this the draft's
# classification is treated as uncertain and we stay on the safer
# path — returning the draft's standby reply instead of force-
# switching into autogen CREATE. Keeps the draft in charge but
# refuses to make an irreversible routing change on a coin flip.
_DRAFT_INTENT_CONFIDENCE = 0.75
# Persona block forwarded to the draft-first dispatcher so a system
# agent's voice comes back on the standby reply. Only populated on
# the system-agent path below; initialised here so the draft-first
# branch (which runs BEFORE that path for non-system-agent requests)
# can reference it safely.
custom_prompt = None
# The system agent's own prompt_id when the system-agent branch below
# takes this turn, else None. Bound here because the custom_prompt
# ladder before get_ans reads it on every request; bound only inside
# that branch, every /chat without a prompt_id raised UnboundLocalError
# there (live 2026-09-13 13:38).
_system_agent_pid = None
task_source = data.get('task_source', 'own')
thread_local_data.set_task_source(task_source)
channel_context = data.get('channel_context', None)
# Thread-local + ALWAYS set (incl. None): a pooled worker thread must never
# inherit a prior request's channel, and concurrent requests must not see
# each other's (the old `thread_local_data.channel_context = …` put it on the
# shared singleton instance, not _local — a cross-request leak).
thread_local_data.set_channel_context(channel_context)
# Which device asked (always set, like channel_context): connecting
# WhatsApp on the phone itself needs a pair code, not a QR that phone
# cannot scan (_start_gateway_qr_pair_push).
thread_local_data.set_client_user_agent(request.headers.get('User-Agent', ''))
# USER PRIORITY: mark user activity so daemon dispatch yields the LLM.
#
# CRITICAL (2026-05-29 feedback-loop fix): only stamp for GENUINE
# user turns. The agent_daemon dispatches goals via the in-process
# Tier-1 path (dispatch.py → hevolve_chat → this chat() handler)
# with request_id='daemon_' (dispatch.py:_daemon_request_id).
# If those daemon dispatches also stamped user activity, the
# `should_yield_to_user()` gate would read "user active" forever:
# daemon tick → dispatch → chat() → stamp → gate stays True →
# normal tick yields → only the 120s STARVATION OVERRIDE ever
# dispatches. Live log evidence: the yield gate stayed blocked
# for ~85821s (~24h) with 1142 starvation overrides — the flywheel
# daemon never ran its normal path, which is the real cause of the
# "84 goals, 0 progress for days" symptom.
#
# A genuine user request — including a user-initiated autonomous
# CREATE ("do it for me") — carries a non-daemon request_id, so it
# still correctly marks the user active. Only the daemon's own
# background dispatches are excluded. Discriminator is the canonical
# dispatch.is_genuine_user_request (testable; single source of truth).
try:
from integrations.agent_engine.dispatch import (
mark_user_chat_activity, is_genuine_user_request)
if is_genuine_user_request(request_id):
mark_user_chat_activity()
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
app.logger.info(f"casual_conv type {casual_conv}")
# Security: sanitize prompt_id to prevent path traversal.
# fullmatch, NOT match: re.match(r'...$', '123\n') accepts a trailing newline
# (the $ anchors before a terminal \n), and prompt_id is interpolated into
# file paths (prompts/{prompt_id}.json) — a newline must never slip through.
if prompt_id is not None:
prompt_id = str(prompt_id)
if not re.fullmatch(r'[a-zA-Z0-9_-]+', prompt_id):
return jsonify({'error': 'Invalid prompt_id format', 'response': None}), 400
# Reel Studio (plan WS-8a, amendment A19): the turn's creation hint, the
# user-assignable tags of the body's goal_tags. Read here, before the
# REUSE ladder below replaces `data` with the agent's own record. Held
# nowhere between turns: her client puts her agent's tags on every turn
# of it (the phone's ReelInbox.markTurn), so the turn whose gather saves
# the record carries them, after a restart or a long gather alike, and
# _save_and_enter_review stamps them.
from core.intelligence_preference import IntelligencePreference
from integrations.agent_engine.marketing_tools import (
agent_is_local_only, user_assignable_goal_tags)
_creation_hint = user_assignable_goal_tags(data.get('goal_tags'))
# Does this turn keep to this node? ONE rule over every source
# (amendment A9 e, ruled with the lanes lane 2026-10-13): the person's
# own toggle (Nunba's Demopage switch), or the goal tags of the turn's
# agent (Reel Studio, decision U4) -- this prompt_id's, with the hint
# this turn carries for it, and the agent an agentic_execute plan names,
# which the turn is routed to only below. Strictness only goes up. An
# input to the lane and model decision below, never a decision of its
# own; in memory only, nothing persisted.
_plan = data.get('agentic_plan') if data.get('agentic_execute') else None
local_only = (intelligence_preference == IntelligencePreference.LOCAL_ONLY
or agent_is_local_only(prompt_id, _creation_hint)
or (isinstance(_plan, dict)
and agent_is_local_only(_plan.get('matched_agent_id'))))
if local_only:
from core.autogen_config import llm_stays_on_premises
# The one answer every reader of the turn's preference sees
# (dispatch_draft_first's user_pref below).
intelligence_preference = IntelligencePreference.LOCAL_ONLY
# The known limit (agreed with the lanes lane): local-only refuses
# the body's tier, speculation and the revenue lane, and the turn
# runs on this node's own backend (core.autogen_config
# .get_autogen_config_list). When that backend is a public API, the
# turn's text still leaves this machine. Said once per process.
if not llm_stays_on_premises() and not getattr(chat, '_local_only_offsite_warned', False):
app.logger.warning(
'chat: local-only turns run on this node\'s own LLM, and it is a '
'public API endpoint (core.autogen_config.llm_stays_on_premises '
'is False): their text leaves this machine')
chat._local_only_offsite_warned = True
# The turn's file references (plan 4.2(e)), bound to this user, agent and
# request before anything runs, so the agent's reel tool finds them.
if data.get('attachments'):
from core.chat_attachments import bind_inbound, parse_inbound
bind_inbound(user_id, prompt_id, request_id, parse_inbound(data['attachments']))
# The lane this turn runs on (revenue or user; daemon is the id's own
# prefix), decided here where the request's auth is in hand and
# registered by request id so the autogen worker thread and the wire
# header read the same answer (core.request_lane, owner policy 2026-10-12).
# The node's own daemon turn is left to the id's prefix rule and to the
# lane dispatch_goal registered for a goal that earns; every other caller
# is registered by what its request proves, replacing whatever the id
# held, so nobody inherits a goal's lane by naming its request id. A
# turn that keeps to this node is never left on the revenue lane.
try:
from core.constants import SYSTEM_IDENTITIES as _SYSTEM_IDENTITIES
from core.request_lane import register_chat_lane
from integrations.agent_engine.dispatch import (
is_genuine_user_request as _lane_is_genuine)
from integrations.commerce.bindings import TENANT as _COMMERCE_TENANT
from integrations.social.auth import decode_jwt as _lane_decode_jwt
register_chat_lane(request_id, _bearer_token, data, getattr(g, 'auth_source', None),
decode_jwt=_lane_decode_jwt, tenant=_COMMERCE_TENANT,
system_identities=_SYSTEM_IDENTITIES, is_genuine=_lane_is_genuine,
local_only=local_only)
except Exception as _lane_err:
app.logger.warning('lane not registered for %s (the id rule decides it): %s',
request_id, _lane_err)
# The turn's model tier and speculation: the body's, unless the turn
# keeps to this node. core.request_lane decides both (amendment A9 e:
# one owner of the turn's model decision); chat() decides neither.
from core.request_lane import chat_turn_models
model_config, speculative = chat_turn_models(data, local_only=local_only)
# Per-request model config override (speculative execution).
#
# The dispatcher sends ONE entry — the tier it selected
# (ModelBackend.to_config_list returns [entry]). Stored raw, every autogen
# agent built from this override runs with a single client, so autogen has
# nothing to fall to when that tier fails and the exception ends the turn
# (measured 2026-09-06: claude-code 503 'at capacity' / 'not on PATH', 6x
# per drive, swallowed by reuse_recipe.get_agent_response). Composing this
# node's own backend on as the TERMINAL entry gives the engine's own ladder
# somewhere to land; the selected tier is still entry 0 and still tried
# first. This is the ONE consumer of the payload key, so both producers
# (speculative_dispatcher, dispatch.py) and all five override readers
# inherit it from here.
if model_config:
thread_local_data.set_model_config_override(
with_local_fallback(model_config))
else:
thread_local_data.clear_model_config_override()
prompt = data.get('prompt', None)
# GUARDRAIL: full pre-dispatch gate on every /chat call
if prompt:
try:
from security.hive_guardrails import GuardrailEnforcer
allowed, reason, prompt = GuardrailEnforcer.before_dispatch(prompt)
if not allowed:
return jsonify({'error': f'Guardrail: {reason}', 'response': None}), 403
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# SECURITY: redact secrets (API keys, tokens, passwords) from user prompts
if prompt:
try:
from security.secret_redactor import redact_secrets
prompt, _redacted_count = redact_secrets(prompt)
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# The prompt as the two gates above left it. Later branches rewrite
# `prompt` for one consumer (gather_info gets the cloud record or the
# wrap-up instruction appended) and then restore the turn's text; they
# restore THIS, never data['prompt'], which would undo both gates.
# Measured in the review of 8c9abe070: the gather path re-read the raw
# body, so a user's 'sk-...' reached gather_info's LLM and the mirror.
_guarded_prompt = prompt
# BUDGET GATE: estimate and log LLM cost before execution
if prompt:
try:
from integrations.agent_engine.budget_gate import estimate_llm_cost_spark
_est_cost = estimate_llm_cost_spark(prompt)
app.logger.debug(f"Estimated LLM cost: {_est_cost} Spark for user={user_id}")
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# #590 — bind the rid BEFORE the speculative block, which RETURNS on the
# should_speculate path. With the bind below that block, a turn taking the
# speculative early return left the rid unbound for the whole rest of the
# request. Moved, not duplicated: the single bind still runs exactly once
# per /chat call, and now on every path including that early return.
#
# SCOPE, MEASURED 2026-09-11 — READ THIS BEFORE CITING THIS COMMENT.
# This move does NOT fix the "LLM-CONTEXT empty request_id at chat_agent
# (thread=spec_expert_N, thread_local_rid='')" lines. An earlier draft of
# this comment claimed it did; a live drive on the deployed fix refuted
# that, and the claim is withdrawn rather than left standing:
# * post-fix drive, 38 llm_outbound rows: 2 populated, 36 empty. The 2
# populated rows are exactly the two probe rids, i.e. the rows emitted
# on the request thread. Every spec_expert row was still empty
# (spec_expert_0/2/3 at 14:41-14:43, after the restart).
# * `speculative` is a REQUEST-BODY field defaulting to False
# (`data.get('speculative', False)`), so this block is not entered by
# ordinary traffic at all.
# * the rows that actually carry source=autogen.reuse reach the pool via
# dispatch_draft_first, called further down this function — AFTER both
# the old and the new bind position — so their rid capture
# (speculative_dispatcher.py:1306) was never affected by this ordering.
# The real hole is upstream of that capture: whatever thread calls
# _schedule_expert_background for the reuse path has no rid bound, so
# `_req_rid` is '' and the worker's re-bind at :1580 (`if request_id:`)
# cannot fire. Attributing that caller is the open work, not this line.
thread_local_data.set_request_id(request_id=request_id)
# Speculative dispatch: fast response + background expert
if speculative and prompt and user_id and prompt_id:
try:
from integrations.agent_engine.speculative_dispatcher import get_speculative_dispatcher
dispatcher = get_speculative_dispatcher()
if dispatcher.should_speculate(str(user_id), str(prompt_id), prompt):
result = dispatcher.dispatch_speculative(
prompt, str(user_id), str(prompt_id))
return jsonify({
'response': result['response'],
'Agent_status': 'Speculative Mode',
'speculation_id': result.get('speculation_id'),
'expert_pending': result.get('expert_pending', False),
'fast_model': result.get('fast_model'),
'latency_ms': result.get('latency_ms'),
})
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# return ""
# Security: Prompt injection detection
if prompt:
try:
from security.prompt_guard import check_prompt_injection
is_safe, reason = check_prompt_injection(prompt)
if not is_safe:
app.logger.warning(f"Prompt injection detected: {reason}")
return jsonify({'error': f'Input rejected: {reason}', 'response': None}), 400
except Exception:
logging.getLogger(__name__).exception("chat: swallowed Exception") # Degrade gracefully
# --- Agentic execution after user consent (Plan Mode → execute) ---
agentic_execute = data.get('agentic_execute', False)
if agentic_execute and prompt and user_id:
agentic_plan = data.get('agentic_plan', {})
matched_agent_id = agentic_plan.get('matched_agent_id')
app.logger.info(f'Agentic execute: matched_agent={matched_agent_id}, user={user_id}')
if matched_agent_id and os.path.exists(os.path.join(PROMPTS_DIR, f'{matched_agent_id}.json')):
# Route to existing agent via chat_agent (reuse_recipe.py)
prompt_id = matched_agent_id
# Fall through to the existing prompt_id routing below
else:
# No matching agent — auto-create one, then execute
# Reuse prompt_id from Plan Mode response if available
new_prompt_id = _creation_prompt_id(
user_id, prompt_id if prompt_id else _next_prompt_id())
auto_response = _autonomous_gather_info(user_id, prompt, new_prompt_id)
_ak = f'{user_id}_{new_prompt_id}'
review_agents[_ak] = True
_touch_agent_timestamp(_ak)
_record_lifecycle('Review Mode', user_id, new_prompt_id,
f'Agentic auto-creation: {prompt[:100]}')
_push_workflow_flowchart(user_id, new_prompt_id, request_id)
# Canonical reply path: _chat_reply fires TTS synth AND the
# chat_messages.persist_and_publish_async fan-out so the
# bubble renders on every Nunba surface subscribed to
# com.hertzai.hevolve.chat.new. (Demopage / Agent page
# / AgentChatPage / RN). Before this fix the raw jsonify
# bypassed both legs, producing the audio-without-bubble
# asymmetry the user reported on the Agent creation page.
return _chat_reply(
user_id, request_id, auto_response,
intent=['FINAL_ANSWER'],
Agent_status='Review Mode',
autonomous_creation=True,
prompt_id=new_prompt_id,
req_token_count=0, res_token_count=0,
history_request_id=[],
user_prompt=prompt,
)
if prompt_id:
# Recipe pull-on-demand: when prompt_id has no local recipe
# but the cloud has one (cross-device user), pull it before
# falling into the create-mode branch. Best-effort - if cloud
# is offline / blob missing / schema mismatch, returns False
# and we proceed to create-mode as before. Closes the
# cross-device gap from the 2026-05-04 Speech Therapy
# silent-fallback incident.
_prompt_path = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
if not os.path.exists(_prompt_path):
try:
from core.recipe_sync import pull_recipe
if pull_recipe(PROMPTS_DIR, prompt_id, str(user_id)):
app.logger.info(
f'recipe_sync: pulled recipe bundle for '
f'prompt_id={prompt_id} from cloud - REUSE path '
f'now reachable')
except Exception as e:
app.logger.debug(f'recipe_sync: pull skipped: {e}')
# System agents (like Nunba) route directly to langchain casual chat
# instead of entering gather_info/CREATE mode
if os.path.exists(_prompt_path):
try:
with open(_prompt_path, 'r') as _pf:
_agent_meta = json.load(_pf)
if _agent_meta.get('is_system_agent'):
# System agent: use its system_prompt for langchain casual chat
_sys_prompt = ''
if _agent_meta.get('flows') and _agent_meta['flows'][0].get('system_prompt'):
_sys_prompt = _agent_meta['flows'][0]['system_prompt']
casual_conv = True
custom_prompt = _sys_prompt
_system_agent_pid = prompt_id
prompt_id = None # Skip CREATE/REUSE routing, fall through to get_ans()
app.logger.info(f"System agent '{_agent_meta.get('name')}' routed to casual chat")
except Exception:
logging.getLogger(__name__).exception("chat: swallowed Exception")
# Per-user lock prevents concurrent requests from corrupting agent state.
# Replaces the global _state_lock for better concurrency.
_user_lock = _get_user_lock(user_id)
with _user_lock:
if _system_agent_pid:
# The system-agent branch above already resolved the persona
# into custom_prompt and nulled prompt_id so this turn reaches
# get_ans(). Falling into the CREATE/REUSE ladder here is what
# made that unreachable: `prompt_id` is None, so the `else`
# below logged 'GATHER JSON doesnot EXISTS' and set
# create_agent=True, sending every system-agent turn into
# gather_info instead of casual chat. Observed live
# 2026-09-11 on a persona agent: the log read
# System agent 'Spider-Man' routed to casual chat
# GATHER JSON doesnot EXISTS
# gather_info turn 1/12
# on every single message. Skipping the ladder is the whole
# intent of `prompt_id = None` above.
pass
elif prompt_id and os.path.exists(os.path.join(PROMPTS_DIR, f'{prompt_id}.json')):
app.logger.info('GATHER JSON EXISTS')
if os.path.exists(os.path.join(PROMPTS_DIR, f'{prompt_id}_0_recipe.json')):
app.logger.info('0 Recipe JSON EXISTS')
file_path = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
with open(file_path, 'r') as f:
data = json.load(f)
no_of_flow = len(data['flows'])-1
app.logger.info(f'GOT LEN OF FLOW AS {no_of_flow}')
if os.path.exists(os.path.join(PROMPTS_DIR, f'{prompt_id}_{no_of_flow}_recipe.json')):
# All flows complete → REUSE
create_agent = set_flags_to_enter_reuse_mode(no_of_flow, user_id, prompt_id) #returns false
else:
# Some flows complete, some not.
# Enter CREATE — recipe() has resume logic (initialize_with_resume)
# that will skip completed flows and resume from where it left off.
# It also detects existing sessions (user_prompt in user_tasks) to
# avoid re-creating agents, making this idempotent.
app.logger.info(f'{no_of_flow} Recipe JSON does not exist — resuming CREATE for remaining flows')
create_agent = True
_ak = f'{user_id}_{prompt_id}'
review_agents[_ak] = True
conversation_agent[_ak] = False
_touch_agent_timestamp(_ak)
else:
app.logger.info('0 Recipe JSON doesnot EXISTS')
# #485 — honor UI create_agent flag; don't force True from
# file-state alone (broke chat-mode for finished agents).
if create_agent or autonomous:
_ak = f'{user_id}_{prompt_id}'
review_agents[_ak] = True
conversation_agent[_ak] = False
_touch_agent_timestamp(_ak)
else:
try:
with open(os.path.join(PROMPTS_DIR, f'{prompt_id}.json'), 'r') as _gpf:
_gate_meta = json.load(_gpf)
if _gate_meta.get('flows') and _gate_meta['flows'][0].get('system_prompt'):
custom_prompt = _gate_meta['flows'][0]['system_prompt']
except Exception as _meta_err:
app.logger.warning("L1 (#485): persona read failed for prompt_id=%r: %s", prompt_id, _meta_err)
else:
app.logger.info('GATHER JSON doesnot EXISTS')
create_agent = True
_ak = f'{user_id}_{prompt_id}'
review_agents[_ak] = False
conversation_agent[_ak] = True
_touch_agent_timestamp(_ak)
# ── Draft-first dispatch (Path 1: default chat + Path 2: system agents) ──
# This runs AFTER the prompt_id branch so we know whether the request
# is bound for autogen CREATE/REUSE. Skipped entirely for agentic
# orchestration — those are multi-step flows a 0.8B draft can't handle.
# For system agents (is_system_agent=True), custom_prompt is already
# populated with the persona and is forwarded so the standby reply
# comes back in character.
#
# prompt_id is in the predicate because it is the SESSION-SHAPE flag —
# the same one casual_conv uses at chatbot_routes:486, `not bool(
# prompt_id or create_agent)`: no prompt_id = casual companion
# session, prompt_id = agent-bound session. create_agent alone is a
# PHASE flag: set_flags_to_enter_reuse_mode returns False because
# reuse is not creation, so gating on it alone sent every REUSE turn
# into draft-first. Live 2026-09-05 agent 18088688973 answered "Hi!
# How can I help you today?" in 2.4s carrying Agent_status=
# 'Draft-First Mode' while its saved recipe never ran; the same
# request 8 minutes earlier had executed google_search over 100s.
# System agents still get draft-first — they null their own
# prompt_id at ~9387 above, before this gate.
_is_agentic_orchestration = bool(prompt_id or create_agent) or bool(autonomous)
# Skip draft-first for non-Latin-script languages — the 0.8B can't produce
# correct native script (outputs garbled Kannada for Tamil, etc.). Going
# straight to the 4B avoids wasted compute on a useless standby reply.
# Skip draft-first for non-Latin-script languages. Canonical set
# lives in core.constants — see also the dispatcher's own guard at
# dispatch_draft_first (belt + suspenders).
from core.constants import NON_LATIN_SCRIPT_LANGS as _NON_LATIN_LANGS
_skip_draft_for_lang = preferred_lang and preferred_lang[:2] in _NON_LATIN_LANGS
if draft_first and not _skip_draft_for_lang and prompt and user_id and not _is_agentic_orchestration:
try:
from integrations.agent_engine.speculative_dispatcher import get_speculative_dispatcher
dispatcher = get_speculative_dispatcher()
# agent_bound=True when prompt_id refers to a real agent on
# disk — the dispatcher uses this signal to never let the
# 0.8B draft short-circuit the specialist on trivial Q&A.
_agent_bound = bool(prompt_id)
# NEVER derive prompt_id from request_id. A prior version
# passed ``str(request_id or 'anon')`` as a fallback to keep
# the dispatcher's partition keys non-empty, but that
# synthetic value leaked through ``_dispatch_expert_langchain``
# into the expert reroute's /chat payload and was
# misinterpreted as a real on-disk agent identifier — causing
# ``_autonomous_gather_info`` to mint a *second* agent named
# after the request_id (live evidence 2026-05-12 request
# 66c63859-… spawned ``prompts/66c63859-…json`` alongside
# the legitimate ``prompts/78570931871.json``). Pass None;
# the dispatcher handles None internally via
# ``speculation_id`` for any keying that needs a string.
from core.teacher_avatar import avatar_id_from
result = dispatcher.dispatch_draft_first(
prompt, str(user_id),
str(prompt_id) if prompt_id else None,
agent_persona=custom_prompt or None,
preferred_lang=preferred_lang,
user_pref=intelligence_preference,
agent_bound=_agent_bound,
# The expert's follow-up reply is spoken as this turn's
# avatar, like this turn's own reply (_chat_reply).
avatar_id=avatar_id_from(data.get('teacher_avatar_id')),
)
# Only commit when the dispatcher actually produced a reply.
# no_draft_model / circuit breaker / guardrail block all leave
# result['response'] empty — fall through to the normal path.
if result.get('response'):
# If the draft classifier flagged a NEW agent creation intent
# with high confidence, fall through to the autogen CREATE
# flow below instead of returning the draft's standby reply.
# This is the SINGLE source of truth for create_agent
# routing — callers no longer need to keyword-match user
# text before calling /chat, the 0.8B draft handles it.
_draft_conf = float(result.get('draft_confidence') or 0.0)
# Language change: the draft detected "talk to me in
# tamil" → language_change="ta". Override preferred_lang
# so the main LLM prompt says "respond in Tamil" and TTS
# routes to a Tamil-capable engine (Indic Parler).
_lang_change = result.get('language_change', '')
if _lang_change:
preferred_lang = _lang_change
app.logger.info(
f'draft classifier: language_change={_lang_change} '
f'— overriding preferred_lang for this request'
)
# Persist so next requests also use the new language
_persist_language(_lang_change)
# ONE predicate for "the draft's create-intent is real
# enough to route on". Both the CREATE branch below and
# the delegate branch further down must read THIS, not the
# raw flag: a turn whose is_create_agent is set but NOT
# trusted used to be refused by both (too uncertain to
# create, yet the flag still suppressed delegation) and
# fell to the `else`, which returns the draft's raw text
# as the answer. Measured live 2026-08-29 21:30: "What is
# 17 multiplied by 4?" -> conf 0.5, delegate 'local',
# is_create_agent True -> a memory-recall ramble. Same
# shape as the 2026-05-07 regression branch 3 was added to
# fix. Untrusted intent is treated as absent, everywhere.
_create_intent_actionable = (
bool(result.get('is_create_agent'))
and _draft_conf >= _DRAFT_INTENT_CONFIDENCE
# A turn that declined agent work (the metered Hive
# API's self-POST, task #120) is not routed into CREATE
# by the draft either: HEVOLVE_DRAFT_FIRST=1 in the
# node's env overrides the body's draft_first False and
# could bring a paid call here (read, not measured).
and agent_tools)
if _create_intent_actionable:
app.logger.info(
'draft classifier: is_create_agent=true '
f'(conf={_draft_conf:.2f}) — routing to autogen CREATE'
)
create_agent = True
_is_agentic_orchestration = True
# Fall through — do NOT return the draft reply; the
# create_agent branch below runs in this same request.
elif _vision_keyword_override(prompt):
# Vision safety-net: the 0.8B draft has no sight,
# so a `is_casual=True` draft reply to "can you see
# me?" would be a confabulation. When the prompt
# clearly asks for camera/scene/screen access, force
# the full LangChain tool path so
# `Visual_Context_Camera` (parse_visual_context)
# can run. Single-source regex lives in
# core.constants.VISION_INTENT_PATTERN.
app.logger.info(
'draft classifier returned casual but prompt '
'matches VISION_INTENT_PATTERN — falling through '
'to LangChain tool path for Visual_Context_Camera'
)
# Fall through — do NOT return the draft standby
# reply; the tool-based path below will run and the
# VLM tool will produce a grounded answer.
elif (_draft_delegates(result)
and not _create_intent_actionable):
# Draft self-assessed: this is a non-casual TASK
# that needs more capability than the 0.8B has
# (delegate='local' = bigger local model + tools;
# delegate='hive' = tier escalation needed). The
# user's design intent (2026-05-07): "only if local
# needs help it shd go via hive" — so try LOCAL
# autogen execution first; hive escalation is
# downstream via dispatch.py if the local agent
# can't satisfy.
#
# Pre-fix this fell through to the `else` standby
# reply ("I'll gather that research..."), promising
# work that never happened. Witnessed 2026-05-07
# 10:24:16 with "open chrome and research AI
# papers" — telemetry showed delegate='hive',
# is_casual=False but reply was the draft's 102-
# char polite ack and no execution kicked off.
#
# Setting autonomous=True (same shape as #105)
# routes to:
# 1. find_matching_agent (catalog 1213+ agents)
# 2. If matched + recipe exists → chat_agent (REUSE)
# 3. If no match → _autonomous_gather_info auto-
# fills identity (or partial-salvage at
# hart_intelligence_entry.py:5817), then
# 4. recipe() runs autogen tool group locally
# (visual_execution / call_visual_task / etc).
# 5. Hive escalation if step 4 can't satisfy
# (downstream in dispatch.py — separate path).
app.logger.info(
f"draft classifier: delegate="
f"{result.get('delegate')!r} "
f"is_casual={result.get('is_casual')!r} (no veto), is_create_agent=False — routing to "
f"the langchain chat (get_ans), NOT autogen CREATE. "
f"get_ans carries FULL_HISTORY (date-recall via "
f"parsing_string -> get_time_based_history SimpleMem) "
f"AND Create_Agent / Agentic_Router, so it answers "
f"recall/Q&A directly or escalates a genuine task via "
f"its own tools."
)
# casual_conv is DELIBERATELY left as the route sent it
# (chatbot_routes:486 `not bool(prompt_id or
# create_agent)`): it is a SESSION-SHAPE flag — no
# prompt_id = casual companion session (draft-first,
# light get_ans), prompt_id = agent-bound session — NOT
# the classifier's per-turn is_casual verdict. A
# 2026-08-24 change overrode it to False here whenever
# the classifier said not-casual; the owner reverted it
# the same day: default-agent turns must keep the casual
# shape, and only prompt_id sessions run agent-bound.
# #118 FIX: do NOT force create_agent/autonomous here.
# Forcing CREATE on EVERY non-casual turn hijacked recall/
# Q&A ("what did we discuss 15 days back") into an 8-action
# execute_windows CREATE plan + review + stall, bypassing
# get_ans — the ONLY path with the working FULL_HISTORY
# date-recall tool. Falling through (no flags, no return)
# reaches get_ans below (the create_agent block no-ops; a
# bare recall has no prompt_id so the REUSE check is skipped
# too). The 2026-05-07 "open chrome and research" regression
# this branch was added for stays fixed: get_ans returns a
# real, tool-grounded answer (escalating a genuine task via
# Create_Agent / Agentic_Router), not the do-nothing draft
# standby. VERIFY LIVE: a genuine multi-step task still
# escalates (Computer_Action/Shell are casual-only, so tasks
# route via Create_Agent -> CREATE).
else:
# Only now — having decided to serve the draft's reply
# as the final answer — fire the background expert.
# The three branches above this one fall through to
# their OWN synchronous full turn for this same prompt
# instead of returning here; scheduling unconditionally
# at classification time (the old behaviour) ran that
# same turn again in the background for those cases —
# the root cause of the 2026-08 duplicate-turn
# investigation. See
# speculative_dispatcher.schedule_expert_for_draft.
dispatcher.schedule_expert_for_draft(result)
return _chat_reply(
user_id, request_id, result['response'],
Agent_status='Draft-First Mode',
speculation_id=result.get('speculation_id'),
expert_pending=result.get('expert_pending', False),
delegate=result.get('delegate'),
draft_model=result.get('draft_model'),
draft_confidence=result.get('draft_confidence'),
is_correction=bool(result.get('is_correction', False)),
is_casual=bool(result.get('is_casual', False)),
is_create_agent=bool(result.get('is_create_agent', False)),
channel_connect=result.get('channel_connect', ''),
language_change=result.get('language_change', ''),
latency_ms=result.get('latency_ms'),
user_prompt=prompt,
preferred_lang=preferred_lang,
)
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# Resume an in-progress creation before the gate below can drop it.
#
# `create_agent` is a PER-MESSAGE guess: it comes from the request payload
# (Nunba sets it while a creation conversation is open) or from the draft
# classifier. Neither survives a mid-flow approval — "Approved, generate
# the recipe" does not read as a create REQUEST, so the classifier returns
# False, the whole creation block below is skipped, and the turn falls
# through to the reuse path. Reuse loads {prompt_id}_0_recipe.json, which
# for an agent still IN REVIEW does not exist yet
# (reuse_recipe.create_schedule:3367), so the turn 500s and the agent is
# left permanently half-built: config on disk, no recipe, and every later
# message a 500 with no way for the user to tell what went wrong.
# Reproduced live 2026-08-21 on prompt_id 1 and again on 7101.
#
# review_agents[...] is the AUTHORITATIVE "this user+prompt is mid-creation"
# flag — _save_and_enter_review sets it when the details are captured, and
# only the review phase clears it. Trust that state over the per-message
# guess; recipe() at the end of Phase 2 is what the flow is waiting for.
if not create_agent and prompt_id:
try:
_resume_lock = _get_user_lock(user_id)
with _resume_lock:
_resuming = review_agents.get(f'{user_id}_{prompt_id}', False)
except Exception:
_resuming = False
if _resuming:
app.logger.info(
f'chat: resuming in-progress creation for {user_id}_{prompt_id} '
f'— review_agents is set, but this message did not classify as '
f'a create request. Staying in the creation flow.')
create_agent = True
if create_agent:
# Generate prompt_id server-side if not provided
if not prompt_id:
prompt_id = _next_prompt_id()
app.logger.info(f'Generated server-side prompt_id={prompt_id} for new agent')
# Per-user lock: snapshot agent state flags (lock NOT held during LLM calls)
_user_lock = _get_user_lock(user_id)
_ak = f'{user_id}_{prompt_id}'
with _user_lock:
_in_review = _ak in review_agents and review_agents[_ak]
_in_convo = _ak in conversation_agent and conversation_agent[_ak]
# Phase 1: Gather Requirements
if not _in_review:
with _user_lock:
review_agents[_ak] = False
_touch_agent_timestamp(_ak)
prompt = _guarded_prompt
# The words the user sent this turn, for _chat_reply's user_prompt:
# it records the user side (conversation mirror, SimpleMem, the
# MemoryGraph) only when given one, and every gather exit below
# left it out, so creation turns were stored as assistant-only
# (live GR7). Taken HERE because `prompt` is rewritten below for
# gather_info (cloud name/goal appended on the first turn, the
# wrap-up instruction on the last), and neither is what the user
# said. tests/unit/test_gather_turn_mirrors_user_side.py
_user_turn_text = prompt
if prompt_id not in first_promts:
first_promts.append(prompt_id)
try:
res = pooled_get(
f'{DB_URL}/getprompt/?prompt_id={prompt_id}', timeout=5).json()
if res and isinstance(res, list) and len(res) > 0:
prompt = prompt+f" name:{res[0]['name']} goal:{res[0]['prompt']}"
else:
app.logger.debug(f'No cloud record for prompt_id={prompt_id} (new agent)')
except Exception:
app.logger.debug(f'Cloud DB unreachable for prompt_id={prompt_id}, using local-only')
if not user_id or not prompt:
return _chat_reply(
user_id, request_id,
'Need user_id and text to create agent',
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
)
if autonomous:
# Check if an existing agent can handle this task before creating new one
try:
from integrations.agentic_router import find_matching_agent
_match = find_matching_agent(prompt, PROMPTS_DIR)
if _match and _match.get('agent_id'):
_mid = _match['agent_id']
_mconfig = os.path.join(PROMPTS_DIR, f'{_mid}.json')
if os.path.exists(_mconfig) and os.path.exists(
os.path.join(PROMPTS_DIR, f'{_mid}_0_recipe.json')):
app.logger.info(
f'Matched existing agent {_match["name"]} ({_mid}) '
f'— routing to REUSE instead of CREATE')
# The matched agent's turn: a tool acting for
# "the calling agent" (its own vote) must see
# _mid, not this request's prompt_id.
with thread_local_data.turn_of(_mid):
return chat_agent(user_id, prompt, _mid, file_id, request_id)
except Exception as _me:
app.logger.debug(f'Agent matching skipped: {_me}')
# No existing agent matches — create new one
# Autonomous dispatch (from daemon or API): LLM self-generates agent config
# AND immediately creates recipe — no human review step needed.
# Full pipeline: gather_info → save config → recipe() → completed
# Background work: its goal's own stable prompt id, and a
# held plan asks under the background limit (hartos-16).
auto_response = _autonomous_gather_info(user_id, prompt, prompt_id,
background=True)
# Now immediately create the recipe so next dispatch enters REUSE
recipe_response = None
_config_path = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
if os.path.exists(_config_path):
try:
try:
from integrations.agent_engine.dispatch import mark_create_start, mark_create_end
mark_create_start(request_id=request_id)
except ImportError:
mark_create_end = None
recipe_response = recipe(user_id, prompt, prompt_id, file_id, request_id,
autonomous=True)
if recipe_response == 'Agent Created Successfully':
with _user_lock:
review_agents[_ak] = True
conversation_agent[_ak] = True
_touch_agent_timestamp(_ak)
try:
_create_social_agent_from_prompt(user_id, prompt_id)
except Exception:
logging.getLogger(__name__).exception("chat: swallowed Exception")
_record_lifecycle('completed', user_id, prompt_id,
f'Autonomous full pipeline: gather + recipe in one shot')
_push_workflow_flowchart(user_id, prompt_id, request_id)
return _chat_reply(
user_id, request_id, recipe_response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='completed',
autonomous_creation=True, prompt_id=prompt_id,
)
else:
app.logger.info(f'Autonomous recipe() returned: {str(recipe_response)[:100]}')
except Exception as e:
import traceback
app.logger.error(f'Autonomous recipe creation failed: {e}\n{traceback.format_exc()}')
# The turn failed: answer the failure sentence, so the
# caller deciding whether work was done sees it.
from core.agent_tools import user_facing_error
recipe_response = user_facing_error(e)
finally:
try:
from integrations.agent_engine.dispatch import mark_create_end
mark_create_end()
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
# Fallback: config saved but recipe failed — next dispatch will retry
with _user_lock:
review_agents[_ak] = True
conversation_agent[_ak] = False
_touch_agent_timestamp(_ak)
_record_lifecycle('Review Mode', user_id, prompt_id,
f'Autonomous creation via dispatch: {prompt[:100]}')
_push_workflow_flowchart(user_id, prompt_id, request_id)
# A recipe() that stopped between steps for the person (#129)
# answers that, not the gather output: the dispatcher holds
# the goal only on a pause it can see (review of 96a9ca9f8:
# it took the gather output for the turn's result and
# recorded a 0-spark strike). The flags above send the next
# dispatch back into recipe(), which resumes the session.
# A recipe() whose turn FAILED answers its failure sentence for
# the same reason: the hive worker and dispatch_goal recognise
# a failed turn only by that sentence (is_user_facing_error).
# Measured on central 2026-10-17: every hosted call answered
# 400/429, /chat answered the gather output, the worker took it
# for the task's result and 17 earning goals were completed
# with nothing done.
# A recipe() that did not start (another turn of the
# conversation runs on a different model, #172) answers that
# too, so the dispatcher holds it instead of judging the
# gather output.
# So does a recipe() that handed its step to a person or the
# expert (_ask_for_help) or ended in the CREATE error envelope
# (#170): the daemon counted the gather output as work done,
# and the hive worker recorded it as the task's result while
# the goal waited for help.
from core.agent_tools import (is_action_error_reply, is_help_pause,
is_held_for_another_turn,
is_user_facing_error, is_user_pause)
return _chat_reply(
user_id, request_id,
recipe_response if (is_user_pause(recipe_response)
or is_held_for_another_turn(recipe_response)
or is_user_facing_error(recipe_response)
or is_help_pause(recipe_response)
or is_action_error_reply(recipe_response))
else auto_response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Review Mode',
autonomous_creation=True, prompt_id=prompt_id,
)
from hartos.gather_agentdetails import gather_info
# --- Turn counter: force-complete after MAX_GATHER_TURNS ---
turn_key = f'{user_id}_{prompt_id}'
_gather_turn_counts[turn_key] = _gather_turn_counts.get(turn_key, 0) + 1
turn_num = _gather_turn_counts[turn_key]
app.logger.info(f'gather_info turn {turn_num}/{MAX_GATHER_TURNS} for {turn_key}')
if turn_num >= MAX_GATHER_TURNS:
# Force completion: ask LLM to wrap up with whatever it has
app.logger.warning(f'gather_info hit max turns ({MAX_GATHER_TURNS}), forcing completion')
prompt = ('Please finalize and return the completed agent configuration NOW with '
'status="completed". Use reasonable defaults for any missing fields. '
'Return the full JSON immediately.')
# What the person saw last in this session (see the mark).
_shown = _gather_reviews_shown.get(turn_key, _NO_REVIEW_SHOWN)
_confirmed_cfg = None
if isinstance(_shown, dict) and _is_plain_yes(_user_turn_text):
# The person answered the review with the plain yes it asks
# for: what is built is the config that was shown, as shown.
# The model is not asked: a small model rewrites the steps on
# every answer, and a rewritten config is shown again, every
# time (#205; live 2026-10-19, agent 342, three times over).
# Any other answer is the person's own and goes to the model
# as before. The mark stays until the save takes it, so a
# save that fails leaves the yes answerable again; `response`
# is read only by the error exit below.
_confirmed_cfg = _shown
response = ('I could not save the agent as shown. Reply yes '
'to try again, or tell me what to change.')
app.logger.info(
"[GATHER] the person confirmed the config shown for %s - "
"building it as shown; the model is not asked (#205)",
prompt_id)
else:
# The model answers, and its reply replaces what the person
# saw last: the exits below write the mark for a review,
# and leave it clear for anything else.
_gather_reviews_shown.pop(turn_key, None)
# The tags the agent is saved with (_save_and_enter_review
# stamps _creation_hint): its plan is written with its own
# tools (gather_agentdetails.plan_writer_tools; 342, 2026-11-04).
response = gather_info(user_id, prompt, prompt_id,
goal_tags=_creation_hint)
app.logger.info('AFTER GATHER INFO')
# --- Helper: save completed agent config and transition to Review ---
def _save_and_enter_review(agent_config):
agent_config['prompt_id'] = prompt_id
agent_config['creator_user_id'] = user_id
# Reel Studio (plan WS-8a, amendments A8 c and A19): the record
# carries the user-assignable goal tags of the creation hint
# this saving turn carries, and only those, so CREATE and
# REUSE build her agent with its tools. Any other record is
# written exactly as before.
from integrations.agent_engine.marketing_tools import (
is_local_only, stamp_goal_tags)
_stamped = stamp_goal_tags(_creation_hint,
agent_config.get('goal_tags'))
if _stamped is not None:
agent_config['goal_tags'] = _stamped
with _user_lock:
conversation_agent[_ak] = False
_touch_agent_timestamp(_ak)
name = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
with open(name, "w") as json_file:
json.dump(agent_config, json_file)
app.logger.info(f"Agent config saved to {name}")
# Sync to cloud DB (non-fatal) -- except a local-only agent's
# gathered goal (decision U4(c)): neither this prompt-list row
# nor, when CREATE completes, its prompts bundle
# (create_recipe.update_agent_creation_to_db) goes to the cloud.
if is_local_only(agent_config.get('goal_tags')):
app.logger.info(f'Agent {prompt_id} is local-only: its gathered '
f'goal is not synced to the cloud prompt list')
else:
try:
pooled_post(
f'{DB_URL}/createpromptlist',
json={'listprompts': [{
'prompt_id': prompt_id,
'prompt': agent_config.get('goal', ''),
'user_id': user_id,
'name': agent_config.get('name', ''),
'is_active': True,
'image_url': agent_config.get('image_url', ''),
}]},
timeout=5)
except Exception:
logging.getLogger(__name__).exception("_save_and_enter_review: swallowed Exception")
with _user_lock:
review_agents[_ak] = True
_touch_agent_timestamp(_ak)
_gather_turn_counts.pop(turn_key, None) # Reset turn counter
_gather_reviews_shown.pop(turn_key, None)
_record_lifecycle('Review Mode', user_id, prompt_id, 'Agent details gathered, entering review')
try:
# Parse gather_info response, or build the config confirmed
new_res = (_confirmed_cfg if _confirmed_cfg is not None
else _read_gather_config(response, prompt_id))
if (new_res.get('status') != 'pending'
and turn_num < MAX_GATHER_TURNS
and not _config_is_buildable(new_res)):
# The steps are the ones the person confirmed; the MODEL
# wrote them in a shape that cannot be built. Ask the
# model again, once, in this turn, before the person is
# asked to type them again (#205). Its answer goes through
# the same exits as the first one.
app.logger.warning(
"[EMPTY-BUILD] status='completed' but not every action "
"is a step for %s (flows: %.300s) - asking the model "
"again (#205)", prompt_id, new_res.get('flows'))
try:
new_res = _read_gather_config(
gather_info(user_id, _BUILDABLE_REASK, prompt_id),
prompt_id)
except Exception as _reask_err:
app.logger.warning(
"[EMPTY-BUILD] the model's second answer for %s "
"could not be read (%s) - asking the person",
prompt_id, _reask_err)
if new_res.get('status') == 'pending' and turn_num < MAX_GATHER_TURNS:
app.logger.info('PENDING STATUS')
if new_res.get('review_details'):
# The model's own review, text: the person answers
# it to the model, whose completed config is built.
_gather_reviews_shown[turn_key] = None
ans = new_res.get('question') or new_res.get('review_details', response)
_record_lifecycle('Creation Mode', user_id, prompt_id, f'Agent creation turn {turn_num}')
return _chat_reply(
user_id, request_id, ans,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
else:
# Completed (or forced completion after max turns)
if turn_num < MAX_GATHER_TURNS and not _config_is_buildable(new_res):
app.logger.warning(
"[EMPTY-BUILD] status='completed' but not every action "
"is a step for %s (flows: %.300s) - asking for the steps "
"instead of saving a config that cannot build (#718)",
prompt_id, new_res.get('flows'))
_record_lifecycle('Creation Mode', user_id, prompt_id,
'Completed without a step for every '
'action - re-asking')
return _chat_reply(
user_id, request_id, _EMPTY_BUILD_REPLY,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
_steps = _config_steps(new_res)
_shown_steps = (_config_steps(_shown)
if isinstance(_shown, dict) else _shown)
if (turn_num < MAX_GATHER_TURNS
and _shown_steps not in (None, _steps)):
# The person confirms what is built (#205). The model
# finished without a review in the last reply, or
# with steps other than the ones shown, so this
# config is shown and becomes the mark: nothing is
# saved until the person answers it, a plain yes
# builds it as shown, anything else goes to the
# model.
_gather_reviews_shown[turn_key] = new_res
app.logger.warning(
"[GATHER] completed %s for %s - showing the config "
"to the person (#205)",
'before any review was shown'
if _shown is _NO_REVIEW_SHOWN else
'with steps other than the ones shown', prompt_id)
_record_lifecycle('Creation Mode', user_id, prompt_id,
'Completed before a review was '
'shown - showing the config')
return _chat_reply(
user_id, request_id, _config_review_text(new_res),
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
app.logger.info('COMPLETED STATUS')
_save_and_enter_review(new_res)
_push_workflow_flowchart(user_id, prompt_id, request_id)
return _chat_reply(
user_id, request_id,
'Got Agent details successfully lets move on to review them one at a time',
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Review Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
except Exception as e:
app.logger.error(f'gather_info parse error on turn {turn_num}: {e}')
if _confirmed_cfg is not None:
# The person confirmed the config shown and its save
# failed (a disk error, a locked file): nothing else is
# saved in its place - the salvage below would save a
# generic config they never saw (#205) - and the mark
# stays, so their next plain yes saves it.
app.logger.warning(
"[GATHER] the config confirmed for %s could not be "
"saved (%s) - asking the person to confirm again (#205)",
prompt_id, e)
_record_lifecycle('Creation Mode', user_id, prompt_id,
'Confirmed config could not be saved')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
# After repeated failures, salvage what we can and move forward.
# For autonomous dispatches (agent daemon), salvage earlier (turn 3)
# to avoid tight retry loops that waste resources.
# For fatal errors (context exceeded, permission denied), salvage immediately.
is_autonomous = request.json.get('autonomous', False)
err_str = str(e)
is_fatal = ('context size' in err_str.lower()
or 'permission denied' in err_str.lower()
or 'errno 13' in err_str.lower())
if is_fatal:
salvage_threshold = 1 # Immediate salvage — retrying won't help
elif is_autonomous:
salvage_threshold = 3
else:
salvage_threshold = MAX_GATHER_TURNS - 2
if turn_num >= salvage_threshold:
app.logger.warning(
f'Too many gather_info failures (turn {turn_num}, '
f'autonomous={is_autonomous}), salvaging partial config')
partial = {
'status': 'pending', # NOT completed: creation FAILED (#718)
'name': f'Agent {prompt_id}',
'agent_name': f'auto.agent{str(prompt_id)[-4:]}',
'goal': prompt or 'General assistant',
'broadcast_agent': 'no',
'personas': [{'name': 'Assistant', 'description': 'General purpose assistant'}],
'flows': [{'flow_name': 'main', 'persona': 'Assistant', 'actions': ['Respond to user'], 'sub_goal': prompt or 'Help the user'}],
'extra_information': f'Auto-generated after {turn_num} gather turns'
}
_save_and_enter_review(partial)
_record_lifecycle('Review Mode', user_id, prompt_id, f'Force-completed after {turn_num} failed turns')
return _chat_reply(
user_id, request_id,
'Agent created with available details. Moving to review.',
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Review Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
_record_lifecycle('Creation Mode', user_id, prompt_id, f'Creation continuing after parse error: {e}')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
user_prompt=_user_turn_text,
)
# Phase 2: Review Phase (re-snapshot flags under lock after Phase 1 may have mutated)
with _user_lock:
_in_review = _ak in review_agents and review_agents[_ak]
_in_convo = _ak in conversation_agent and conversation_agent[_ak]
if _in_review and not _in_convo:
try:
from integrations.agent_engine.dispatch import mark_create_start, mark_create_end
mark_create_start(request_id=request_id)
except ImportError:
mark_create_end = None
try:
# The plan saved here is one the person approved (or the
# gather's own after repeated failures): it runs freed, as
# the after-restart branch below runs it. Owner 2026-11-05:
# Phase 2 is freed too; the Pad sends create_agent on every
# turn, so its approved Reel Studio plan runs here.
response = recipe(user_id, prompt, prompt_id, file_id, request_id,
autonomous=True)
finally:
try:
from integrations.agent_engine.dispatch import mark_create_end as _mce
_mce()
except ImportError:
logging.getLogger(__name__).debug("chat: swallowed ImportError")
if response in ('Agent Created Successfully',
'Agent Already Created Successfully'):
with _user_lock:
conversation_agent[_ak] = True
_touch_agent_timestamp(_ak)
# Bridge: auto-create social identity for this agent
try:
_create_social_agent_from_prompt(user_id, prompt_id)
except Exception as e:
app.logger.debug(f"Social agent bridge skipped: {e}")
_record_lifecycle('completed', user_id, prompt_id, 'Agent creation completed successfully')
_push_workflow_flowchart(user_id, prompt_id, request_id)
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='completed',
)
_record_lifecycle('Review Mode', user_id, prompt_id, 'Agent details being reviewed')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Review Mode',
)
# Phase 3: Evaluation Phase
if _in_review and _in_convo:
return evaluate_agent_after_creation_in_review(file_id, prompt, prompt_id, request_id, user_id)
if prompt_id and os.path.exists(os.path.join(PROMPTS_DIR, f'{prompt_id}.json')):
_ak = f'{user_id}_{prompt_id}' # Ensure compound key available for reuse path
with open(os.path.join(PROMPTS_DIR, f'{prompt_id}.json'), "r") as file:
created_json = json.load(file)
if chat_agent is None:
return _chat_reply(
user_id, request_id,
'Agent reuse module is unavailable. Please check server dependencies.',
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
)
# A config can exist with NO recipe. The gather phase writes
# {prompt_id}.json with status 'completed', which means "details
# gathered" — NOT "recipe built". Reuse then loads
# {prompt_id}_0_recipe.json unconditionally
# (reuse_recipe.create_schedule:3367), so that gap surfaced as a
# 500 on EVERY message to such an agent, forever: the agent was
# bricked at birth and the user got an opaque Internal Server
# Error with no way to tell what was wrong. Observed live
# 2026-08-21 on research.local.scout (prompt_id 1):
# FileNotFoundError: prompts\1_0_recipe.json
# _flow_recipe_exists is the daemon's existing CREATE-vs-REUSE
# test (agent_daemon.py:36) — the classifier this route simply
# never consulted. Reused rather than re-implemented.
try:
from integrations.agent_engine.agent_daemon import _flow_recipe_exists
except Exception:
_flow_recipe_exists = None
if _flow_recipe_exists is not None and not _flow_recipe_exists(prompt_id):
# A config with no flow recipe means creation is UNFINISHED —
# and _flow_recipe_exists is the CREATE-vs-REUSE classifier, so
# this turn belongs to CREATE. Steward rule (2026-08-22): the
# state machine finishes the recipe AUTONOMOUSLY rather than
# handing the user a "recipe missing" message about a decision
# the classifier already made. This is also the resume path
# for creations cut mid-flow (GroupChat max_round exhaustion at
# ~30 rounds ≈ 300s on the local 4B): recipe() rebuilds
# flow/action state via initialize_with_resume — banked
# per-action recipes count as done — and its completion path is
# the one that banks {pid}_{flow}_recipe.json, syncs to central
# (update_agent_creation_to_db), and adverts to peers.
_agent_label = created_json.get('name') or created_json.get('agent_name') or prompt_id
# No importability speculation here (steward, 2026-08-22): a
# missing recipe FILE is agent state, and this branch's whole
# job is routing that state into recipe(). Whether the module
# imported is the import guard's concern, at :461, where it is
# already logged.
app.logger.info(
f'chat: agent {prompt_id} has a config but no flow-0 recipe — '
f'routing this reuse turn into creation to finish it '
f'autonomously (resume-aware).')
_rr_lock = _get_user_lock(user_id)
with _rr_lock:
# Mark mid-creation so follow-up turns classify as create
# (the resume guard above keys on review_agents).
review_agents[_ak] = True
conversation_agent[_ak] = False
_touch_agent_timestamp(_ak)
# autonomous=True carries the steward rule above into CREATE: the
# approved plan runs with the AUTONOMOUS prompt (2026-11-04: on
# the person's request id it read INTERACTIVE and Reel Studio,
# agent 342, stalled on step 2 asking her questions).
response = recipe(user_id, prompt, prompt_id, file_id, request_id, autonomous=True)
if response in ('Agent Created Successfully',
'Agent Already Created Successfully'):
with _rr_lock:
conversation_agent[_ak] = True
_touch_agent_timestamp(_ak)
try:
_create_social_agent_from_prompt(user_id, prompt_id)
except Exception as e:
app.logger.debug(f"Social agent bridge skipped: {e}")
_record_lifecycle('completed', user_id, prompt_id,
'Recipe finished autonomously from reuse routing')
return _chat_reply(
user_id, request_id,
f'"{_agent_label}" is ready — its recipe is now complete. '
f'Ask it again and it will run.',
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='completed', prompt_id=prompt_id,
)
_record_lifecycle('Creation Mode', user_id, prompt_id,
'Autonomous recipe generation in progress (from reuse routing)')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0, history_request_id=[],
Agent_status='Creation Mode', prompt_id=prompt_id,
)
response = chat_agent(user_id,prompt,prompt_id,file_id,request_id)
# --- Step 17: Check if the reuse agent intelligently decided to create a new agent ---
# Two detection mechanisms:
# 1. Autogen create_new_agent tool (intelligent — LLM decides via tool call)
# 2. Response text pattern matching (fallback for structured agent output)
user_prompt = f'{user_id}_{prompt_id}'
if not review_agents.get(_ak) and not create_agent:
# Check autogen tool signal first (intelligent detection)
from hartos.reuse_recipe import creation_signals
if user_prompt in creation_signals:
signal = creation_signals.pop(user_prompt)
agent_desc = signal.get('description', '')
is_auto = signal.get('autonomous', False)
app.logger.info(f'Autogen create_new_agent tool fired: desc="{agent_desc}", autonomous={is_auto}')
new_prompt_id = _next_prompt_id()
if is_auto:
# Autonomous: run gather_info with LLM-generated answers
new_prompt_id = _creation_prompt_id(user_id, new_prompt_id)
auto_response = _autonomous_gather_info(user_id, agent_desc, new_prompt_id)
_ak_new = f'{user_id}_{new_prompt_id}'
review_agents[_ak_new] = True
_touch_agent_timestamp(_ak_new)
_record_lifecycle('Review Mode', user_id, new_prompt_id, f'Autonomous creation from reuse: {agent_desc[:100]}')
return _chat_reply(
user_id, request_id, auto_response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Review Mode',
autonomous_creation=True,
prompt_id=new_prompt_id,
)
else:
_record_lifecycle('Reuse Mode', user_id, new_prompt_id, f'Creation suggested from reuse: {agent_desc[:100]}')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Reuse Mode',
creation_suggested=True,
suggested_agent_description=agent_desc,
prompt_id=new_prompt_id,
)
# Fallback: pattern matching on agent response text
if _response_signals_creation(response):
app.logger.info('Reuse agent response text signals new agent creation needed')
_record_lifecycle('Reuse Mode', user_id, prompt_id, 'Creation suggested via response pattern')
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Reuse Mode',
creation_suggested=True,
)
_record_lifecycle('Reuse Mode', user_id, prompt_id, 'Agent reused for conversation')
_resonance = _tune_resonance_after_chat(user_id, prompt, response)
# Route through _chat_reply so TTS fires on the reuse path too
# (the legacy jsonify here skipped synth, causing the 2026-04-11
# "hi got text but no audio" regression).
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'], req_token_count=0, res_token_count=0,
history_request_id=[], Agent_status='Reuse Mode', **_resonance,
)
if prompt_id:
try:
res = pooled_get(
f'{DB_URL}/getprompt/?prompt_id={prompt_id}').json()
# use config for url
custom_prompt = res[0]['prompt']
if res[0]['prompt'] == 'Learn Language':
app.logger.info(
'found Learn languague getting user preffered language')
lang = pooled_post('{}/getstudent_by_user_id'.format(DB_URL),
data=json.dumps({"user_id": user_id})).json()
language = lang['preferred_language'][:2]
app.logger.info(f'user preffered language is {language}')
custom_prompt = custom_prompt + \
f' The Language selected is {language}'
app.logger.info(f"custom prompt is: {custom_prompt}")
except Exception as e:
app.logger.error(f'failed to get prompt from id:- {prompt_id}: {e}')
custom_prompt = Hevolve
elif probe:
custom_prompt = PROBE_TEMPLATE
prompt_id = 0
elif intermediate:
custom_prompt = INTERMEDIATE_CONTINUATION
prompt_id = 0
elif _system_agent_pid:
# custom_prompt already holds the system agent's persona (the
# branch above nulled prompt_id so the turn reaches get_ans). The
# `else` replaced it with Hevolve whenever draft-first did not
# answer: live 2026-09-13, Spider-Man replied "Hi! I'm Qwen".
# Its own id goes back so get_ans builds the identity block from
# its prompts/.json and scopes memory to it; with 0 the
# identity read "You are Hevolve" (hartos/agent_identity.py).
prompt_id = _system_agent_pid
else:
custom_prompt = Hevolve # use Hevolve from config/template
prompt_id = 0
app.logger.info(f'{custom_prompt}-->{prompt_id}')
post_dict = {'user_id': user_id, 'status': 'INITIALIZED', 'task_name': "CHAT",
'uid': request_id, 'task_id': f"CHAT_{str(request_id)}", 'request_id': request_id}
publish_async('com.hertzai.longrunning.log', post_dict)
thread_local_data.set_user_id(user_id=user_id)
thread_local_data.set_req_token_count(value=0)
thread_local_data.set_res_token_count(value=0)
thread_local_data.set_recognize_intents()
thread_local_data.set_global_intent(global_intent=req_tool)
thread_local_data.set_prompt_id(prompt_id)
# The guarded text again: the create branch above may have rewritten
# `prompt` for gather_info (see _guarded_prompt).
prompt = _guarded_prompt
if probe:
prompt = ''
app.logger.info(
"the time taken before get ans in main api is %s seconds", time.time() - start_time)
ans_start_time = time.time()
ans = get_ans(casual_conv, req_tool, user_id=user_id,
query=prompt, custom_prompt=custom_prompt, preferred_lang=preferred_lang)
app.logger.info("the time taken by get ans in main api is %s seconds",
time.time() - ans_start_time)
# --- Check if LLM's Agentic_Router tool fired during get_ans() ---
if thread_local_data.get_agentic_requested():
task_desc = thread_local_data.get_agentic_task_description()
plan_steps = thread_local_data.get_agentic_plan_steps()
matched_agent = thread_local_data.get_agentic_matched_agent_id()
thread_local_data.clear_agentic_flags()
app.logger.info(f'Agentic_Router tool fired: task="{task_desc[:80] if task_desc else ""}", '
f'steps={len(plan_steps)}, matched_agent={matched_agent}')
return _chat_reply(
user_id, request_id, ans,
intent=['FINAL_ANSWER'],
Agent_status='Plan Mode',
agentic_plan={
'task_description': task_desc,
'steps': plan_steps,
'matched_agent_id': matched_agent,
'requires_consent': True,
},
# A plan proposed inside a system agent's chat is a new agent;
# the system agent's own id would send its approval back into
# that agent's casual chat.
prompt_id=(prompt_id if prompt_id and not _system_agent_pid
else _next_prompt_id()),
req_token_count=thread_local_data.get_req_token_count(),
res_token_count=thread_local_data.get_res_token_count(),
history_request_id=thread_local_data.get_reqid_list(),
)
# --- Step 16c: Check if LLM's Create_Agent tool fired during get_ans() ---
if thread_local_data.get_creation_requested():
agent_description = thread_local_data.get_creation_description()
is_autonomous = thread_local_data.get_creation_autonomous()
new_prompt_id = _next_prompt_id()
thread_local_data.clear_creation_flags()
app.logger.info(f'LLM Create_Agent tool fired: desc="{agent_description}", autonomous={is_autonomous}')
if is_autonomous:
# Autonomous: run gather_info with LLM-generated answers
new_prompt_id = _creation_prompt_id(user_id, new_prompt_id)
auto_response = _autonomous_gather_info(user_id, agent_description, new_prompt_id)
_ak_new = f'{user_id}_{new_prompt_id}'
review_agents[_ak_new] = True
_touch_agent_timestamp(_ak_new)
_record_lifecycle('Review Mode', user_id, new_prompt_id, f'Autonomous creation via LLM tool: {agent_description[:100]}')
return _chat_reply(
user_id, request_id, auto_response,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Review Mode',
autonomous_creation=True,
prompt_id=new_prompt_id,
)
else:
# Interactive: start gather_info, return first question
from hartos.gather_agentdetails import gather_info
response = gather_info(user_id, agent_description, new_prompt_id)
try:
new_res = _parse_plan_reply(response)
if isinstance(new_res, list):
for item in reversed(new_res):
if isinstance(item, dict) and 'status' in item:
new_res = item
break
else:
new_res = {}
if isinstance(new_res, dict) and new_res.get('status') == 'pending':
resp_text = new_res.get('question', new_res.get('review_details', ans))
else:
resp_text = ans
except Exception:
resp_text = ans
_record_lifecycle('Creation Mode', user_id, new_prompt_id, f'Interactive creation via LLM tool: {agent_description[:100]}')
return _chat_reply(
user_id, request_id, resp_text,
intent=['FINAL_ANSWER'],
req_token_count=0, res_token_count=0,
history_request_id=[],
Agent_status='Creation Mode',
prompt_id=new_prompt_id,
)
if req_tool == 'Image_Inference_Tool':
action_response = pooled_post(f'{DB_URL}/create_action',)
payload = json.dumps({
"conv_id": None,
"user_id": user_id,
"action": f"{ans}",
"zeroshot_label": "Image Inference",
"gpt3_label": "Visual Context"
})
headers = {
'Content-Type': 'application/json'
}
action_response = pooled_post(f'{DB_URL}/create_action', headers=headers, data=payload)
_resonance = _tune_resonance_after_chat(user_id, prompt, ans) if ans else {}
if ans != "":
post_dict = {'user_id': user_id, 'status': 'FINISHED', 'task_name': "CHAT",
'uid': request_id, 'task_id': f"CHAT_{str(request_id)}", 'request_id': request_id,
**_resonance}
publish_async('com.hertzai.longrunning.log', post_dict)
# TTS fires below via _chat_reply — don't duplicate the call here.
else:
post_dict = {'user_id': user_id, 'status': 'ERROR', 'task_name': "CHAT", 'uid': request_id,
'task_id': f"CHAT_{str(request_id)}", 'request_id': request_id, 'failure_reason': 'Got null response from GPT'}
publish_async('com.hertzai.longrunning.log', post_dict)
end_time = time.time()
elapsed_time = end_time - start_time
app.logger.info(f"time taken for this full call is {elapsed_time}")
# If the Navigate_App tool fired during get_ans(), lift the resolved
# ui_actions into the response so the Nunba LiquidActionBar renders
# clickable page chips without a separate round-trip. Cleared afterward
# so the next request on the same thread starts fresh.
_ui_actions = thread_local_data.get_ui_actions()
if _ui_actions:
thread_local_data.clear_ui_actions()
return _chat_reply(
user_id, request_id, ans,
intent=thread_local_data.get_recognize_intents(),
req_token_count=thread_local_data.get_req_token_count(),
res_token_count=thread_local_data.get_res_token_count(),
history_request_id=thread_local_data.get_reqid_list(),
ui_actions=_ui_actions,
user_prompt=prompt,
**_resonance,
)
def evaluate_agent_after_creation_in_review(file_id, prompt, prompt_id, request_id, user_id):
if chat_agent is None:
return _chat_reply(
user_id, request_id,
'Agent reuse module is unavailable. Please check server dependencies.',
intent=['FINAL_ANSWER'], req_token_count=0, res_token_count=0,
history_request_id=[], Agent_status='Evaluation Mode',
)
response = chat_agent(user_id, prompt, prompt_id, file_id, request_id)
_record_lifecycle('Evaluation Mode', user_id, prompt_id, 'Agent being evaluated after creation')
_resonance = _tune_resonance_after_chat(user_id, prompt, response)
return _chat_reply(
user_id, request_id, response,
intent=['FINAL_ANSWER'], req_token_count=0, res_token_count=0,
history_request_id=[], Agent_status='Evaluation Mode', **_resonance,
)
def set_flags_to_enter_reuse_mode(no_of_flow, user_id, prompt_id=''):
app.logger.info(f'{no_of_flow} Recipe Json exist Going to reuse')
create_agent = False
_ak = f'{user_id}_{prompt_id}'
# All flow recipes exist -> the agent is BUILT; this turn is a
# conversation. review=True here sent every first turn on a
# completed agent into Phase-2 recipe(), which short-circuited
# 'Agent Already Created Successfully' — raw 34-char stub as the
# visible reply, message never executed, 'resumed - action
# complete' churn 16x (live 2026-08-23 09:42, #684).
review_agents[_ak] = False
conversation_agent[_ak] = True
_touch_agent_timestamp(_ak)
return create_agent
@app.route('/time_agent',methods=['POST'])
def time_agent():
app.logger.info('GOT REQUEST IN TIME AGENT API')
data = request.get_json()
task_description = data.get('task_description',None)
user_id = data.get('user_id',None)
request_from = data.get('request_from',"Reuse")
prompt_id = data.get('prompt_id',None)
action_entry_point = data.get('action_entry_point',0)
if not task_description or not user_id or not prompt_id:
return jsonify({'error':'user_id or task_description or prompt_id is missing'}), 404
app.logger.info(f'GOT user_id:{user_id} & prompt_id:{prompt_id} & task_description:{task_description}')
# Support both string and numeric IDs
_uid = int(user_id) if str(user_id).isdigit() else str(user_id)
_pid = int(prompt_id) if str(prompt_id).isdigit() else str(prompt_id)
if request_from == 'Reuse':
res = time_based_execution(str(task_description), _uid, _pid, action_entry_point)
else:
res = time_execution(str(task_description), _uid, _pid, action_entry_point)
return jsonify({'response':f'{res}'}), 200
@app.route('/api/vlm/stop', methods=['POST'])
@require_local_or_token_csrf_safe
def vlm_stop():
"""Halt an in-progress VLM computer-use loop for a (user, prompt).
Ports OmniParser/omnitool/gradio/agentic_rpc.py:/stop into HARTOS
proper. When the user clicks Stop in Nunba's indicator window,
the request lands here, the loop's stop flag is set, and the
next iteration of run_local_agentic_loop exits cleanly with
exit_reason='stopped' before another action runs on the screen.
Auth: ``@require_local_or_token_csrf_safe``. Nunba's bundled install
POSTs here from desktop/indicator_window.py over localhost without a
JWT — the decorator allows that. Remote callers (production HARTOS
on regional/central tier) must send ``Authorization: Bearer
``. Without this gate, any unauthenticated
network client could bulk-halt another user's VLM sessions.
The ``_csrf_safe`` variant adds an Origin/Referer header check on
top of the localhost gate, closing the same-machine
cross-origin-browser-CSRF vector: a malicious page on a different
origin in the same browser cannot trigger a Stop on the user's
active VLM session. Native desktop callers (the indicator window)
don't send Origin/Referer at all, so they pass through unaffected.
Body (JSON):
{"user_id": "", "prompt_id": ""}
{"scope": "node"}
Response:
{"status": "stopped"|"no_active_session", "user_id", "prompt_id"}
{"status", "scope": "node", "stopped_sessions"}
Empty body / missing prompt_id → bulk-stop every active session
for the given user_id. Empty user_id is rejected (bulk-stop
across all users would be a foot-gun).
scope=node stops every loop on this node, whoever it runs as, and is
what the indicator's Stop sends: the desktop has one screen, and each
loop is registered under its agent's creator, so the owner's per-user
stop left another creator's loop driving the mouse (live 2026-09-14,
agent 88659566083). Only a caller on this machine may send it
(_is_local_request, the rule the decorator applies); a token holder
elsewhere gets 403 and keeps the per-user stop. prompt_id is ignored.
"""
data = request.get_json(silent=True) or {}
user_id = data.get('user_id')
prompt_id = data.get('prompt_id')
from integrations.vlm.local_loop import (
request_stop, list_active_sessions,
)
if data.get('scope') == 'node':
from core.auth_local import client_key as _client_key
if not _is_local_request():
app.logger.warning(f'vlm_stop: node-wide stop refused for '
f'{_client_key()}: not on this machine')
return jsonify({
'error': 'forbidden',
'message': 'scope=node is only accepted from this machine.',
}), 403
stopped = [{'user_id': uid, 'prompt_id': pid}
for uid, pid in list_active_sessions()
if request_stop(uid, pid)]
app.logger.warning(f'vlm_stop: node-wide stop from '
f'{_client_key()}: {len(stopped)} loop(s) '
f'{stopped}')
return jsonify({
'status': 'stopped' if stopped else 'no_active_session',
'scope': 'node',
'stopped_sessions': stopped,
}), 200
if not user_id:
return jsonify({'error': 'user_id required'}), 400
if prompt_id:
found = request_stop(str(user_id), str(prompt_id))
return jsonify({
'status': 'stopped' if found else 'no_active_session',
'user_id': str(user_id),
'prompt_id': str(prompt_id),
}), 200
# Bulk-stop: every session for this user_id
stopped = []
for uid, pid in list_active_sessions():
if uid == str(user_id):
if request_stop(uid, pid):
stopped.append({'user_id': uid, 'prompt_id': pid})
return jsonify({
'status': 'stopped' if stopped else 'no_active_session',
'user_id': str(user_id),
'stopped_sessions': stopped,
}), 200
@app.route('/visual_agent',methods=['POST'])
def visual_agent():
app.logger.info('GOT REQUEST IN Visual AGENT API')
data = request.get_json()
task_description = data.get('task_description',None)
user_id = data.get('user_id',None)
request_from = data.get('request_from',"Reuse")
prompt_id = data.get('prompt_id',None)
if not task_description or not user_id or not prompt_id:
return jsonify({'error':'user_id or task_description or prompt_id is missing'}), 404
app.logger.info(f'GOT user_id:{user_id} & prompt_id:{prompt_id} & task_description:{task_description}')
# Quick VLM health check — delegate to lightweight_backend (single path)
from integrations.vision.lightweight_backend import get_vision_backend
_vlm_backend = get_vision_backend()
if not _vlm_backend.is_available():
return jsonify({'response': 'Visual agent: no VLM server available. Start llama-server with --mmproj or MiniCPM.', 'vlm_status': 'offline'}), 200
_uid = int(user_id) if str(user_id).isdigit() else str(user_id)
_pid = int(prompt_id) if str(prompt_id).isdigit() else str(prompt_id)
# Computer use mode: use VLM point_and_act directly with screenshot
mode = data.get('mode', 'auto') # 'computer_use', 'camera', 'auto'
if mode == 'computer_use' or (mode == 'auto' and request_from != 'Reuse'):
# This branch grabs the owner's screen, so it needs their
# screen_capture consent: the ask VisionService's capture loop files
# (vision_service._consent_ok). The owner is whose machine this is
# (HEVOLVE_OWNER_USER_ID), never the caller's user_id; with no owner,
# or a check that fails, nothing is grabbed (#66).
from integrations.social.consent_service import central_lacks
if central_lacks('screen_capture'):
return jsonify({'response': 'Not run: central has no screen.', 'vlm_status': 'no_screen'}), 200
_owner = os.environ.get('HEVOLVE_OWNER_USER_ID')
_allowed = False
if _owner:
try:
from integrations.social.models import db_session
from integrations.social.consent_service import ConsentService
with db_session(commit=True) as db:
_allowed = ConsentService.check_or_request(
db, _owner, 'screen_capture')
except Exception as e:
app.logger.warning(f'visual_agent: screen_capture check failed: {e}')
if not _allowed:
return jsonify({'response': 'Not run: the owner of this computer has not allowed agents to see this screen.', 'vlm_status': 'consent_required'}), 200
try:
from integrations.vlm.qwen3vl_backend import get_qwen3vl_backend
import base64, io
from PIL import ImageGrab, Image
backend = get_qwen3vl_backend()
screenshot = ImageGrab.grab()
from integrations.vlm.local_computer_tool import VLM_IMG_W, VLM_IMG_H
img_resized = screenshot.resize((VLM_IMG_W, VLM_IMG_H), Image.LANCZOS)
buf = io.BytesIO()
img_resized.save(buf, 'JPEG', quality=50)
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
result = backend.point_and_act(b64, str(task_description))
return jsonify({
'response': result.get('reasoning', ''),
'action': result.get('action', 'none'),
'screen_x': result.get('screen_x', 0),
'screen_y': result.get('screen_y', 0),
'norm_x': result.get('norm_x', 0),
'norm_y': result.get('norm_y', 0),
'text': result.get('text', ''),
'done': result.get('done', False),
'strategy': result.get('strategy', ''),
'latency': result.get('latency', 0),
'vlm_status': 'ok',
}), 200
except Exception as e:
app.logger.error(f'Computer use error: {e}')
return jsonify({'response': f'Computer use error: {str(e)[:200]}', 'vlm_status': 'error'}), 200
# Camera/legacy mode: use existing visual agent pipeline
try:
if request_from == 'Reuse':
res = visual_based_execution(str(task_description), _uid, _pid)
else:
res = visual_execution(str(task_description), _uid, _pid)
if res is None:
return jsonify({'response': 'No camera frame available. Enable camera or send a frame via channel.', 'vlm_status': 'no_frame'}), 200
return jsonify({'response': f'{res}'}), 200
except KeyError:
return jsonify({'response': 'No active visual agent session. Send a frame first via camera or channel.', 'vlm_status': 'no_session'}), 200
except Exception as e:
app.logger.error(f'Visual agent error: {e}')
return jsonify({'response': f'Visual agent error: {str(e)[:100]}', 'vlm_status': 'error'}), 200
@app.route('/response_ack',methods=['POST'])
def response_ack():
app.logger.info('GOT REQUEST IN response_ack')
data = request.get_json()
user_id = data.get('user_id',None)
request_id = data.get('request_id',None)
thread_local_data.set_request_id(request_id=request_id)
prompt_id = data.get('prompt_id',None)
return jsonify({'status': 'acknowledged'}), 200
@app.route('/api/agent/approval', methods=['POST'])
def agent_approval():
"""Process a user's approval decision on an agent capability request.
The frontend LiquidUI overlay (see liquid_ui_service.py:3452) POSTs
`{agent_id, action, decision}` when the user clicks Approve / Deny
on a consent card the agent raised via _request_capability_consent
or _request_screen_consent. Actions we honour:
enable_camera → VisionService.start('full') (camera+screen feed)
enable_screen → VisionService.start('lite') (screen only)
Both route through _apply_embodied_toggle in the channels admin API
so the manual settings toggle and the agentic approval path share
ONE start/stop implementation — no parallel lifecycle. Since the
consent migration that path is reached through ConsentService
(_embodied_feed_from_consent), which every answer surface shares:
this route, the privacy page, and a consent card answered on the
desktop, on the floating companion or on a phone.
The DECISION itself is recorded as a UserConsent row by
ConsentService.record_capability_decision. It used to exist only as a
config flag and a log line here, so an approval could not be revoked,
never appeared on the privacy page, left no audit entry, and a Deny was
re-asked on the very next poll because nothing marked it decided.
"""
try:
data = request.get_json(silent=True) or {}
# The person's answer to an ask an agent put to them, from whichever
# surface they answered on, for any component (a file_request's
# picked files, a form's values, a choice):
# integrations.agent_engine.person_ask. First, because it carries
# no decision word; the answerer is the caller's VERIFIED identity,
# never a body field, by the rule the commerce and game-sound cards
# use. Every branch below keeps its own shape.
if data.get('ask_id'):
from integrations.agent_engine.person_ask import answer_from_request
_ask_body, _ask_code = answer_from_request(request, data)
return jsonify(_ask_body), _ask_code
agent_id = data.get('agent_id', '')
# kept as sent as well: a game sound's state is camelCase and its
# memo key exact (hartos-3a F5)
action_raw = str(data.get('action', '')).strip()
action = action_raw.lower()
decision = str(data.get('decision', '')).strip().lower()
if decision not in ('approve', 'approved', 'allow', 'yes', 'deny', 'denied', 'no'):
return jsonify({'status': 'error', 'reason': 'invalid decision'}), 400
approved = decision in ('approve', 'approved', 'allow', 'yes')
# A McGroce payment (ap2_pay:) or merchant / product
# draft (merchant_onboard: / merchant_sku:). Not a
# capability consent, so answered before the consent record below,
# and the approver is the caller's VERIFIED JWT identity -- never a
# body field: an agent must not be able to pay on its own say-so.
# One implementation: integrations/commerce/approvals.py.
try:
from integrations.commerce.approvals import (
answer_commerce_approval, approver_from_request,
is_commerce_action)
except Exception as _com_exc: # never costs a consent answer
app.logger.warning(f'agent_approval: commerce unavailable: {_com_exc}')
is_commerce_action = None
# ...but a commerce answer must not fall through to the consent
# record below: that would write a consent row against the owner
# for a payment and answer as if something were applied.
from integrations.commerce import COMMERCE_ACTION_PREFIXES
if action.startswith(COMMERCE_ACTION_PREFIXES):
return jsonify({'status': 'error', 'action': action_raw,
'reason': 'payments are unavailable on this '
'node right now; nothing was '
'approved or charged'}), 503
if is_commerce_action and is_commerce_action(action):
_c_body, _c_code = answer_commerce_approval(
action_raw, approved, approver_from_request(request))
return jsonify(_c_body), _c_code
# Record first, on BOTH branches: the row is the answer, and writing
# it also stops or starts the feed through the one actuator and tells
# every other surface to drop its copy of the card. The owner of
# this desktop owns the camera and the screen, so the row is theirs
# — never the caller's user_id (#66).
_owner_id = os.environ.get('HEVOLVE_OWNER_USER_ID')
_recorded = None
if _owner_id:
try:
from integrations.social.models import db_session
from integrations.social.consent_service import ConsentService
with db_session(commit=True) as _cdb:
_recorded = ConsentService.record_capability_decision(
_cdb, _owner_id, action, approved,
agent_id=agent_id or None)
except Exception as _consent_exc:
# A recording failure must not swallow the owner's answer —
# but it must be loud, because an unrecorded grant is one
# that cannot be revoked.
app.logger.error('agent_approval: consent not recorded for '
'action=%s: %s', action, _consent_exc)
else:
app.logger.warning('agent_approval: consent not recorded — no '
'owner identity (HEVOLVE_OWNER_USER_ID unset)')
if str(action or '').startswith('game_sound:'):
# A game's sound, kept or turned down on the card an agent raised
# (core/agent_tools.offer_sound_for_review). Not a consent: a
# denial falling through to the branch below published
# {'type':'consent', 'decision':'denied'} on the VISION topic for
# a piece of music while the memo kept serving the take they had
# just refused, and an approval fell to the no-mapping branch and
# was never recorded. Whose choice it is -- the agent's creator
# for everyone, anyone else for themselves alone (owner
# 2026-10-17) -- is answer_game_sound_card; the answerer is the
# caller's VERIFIED identity, never a body field, by the same
# rule the commerce cards use.
reason = str(data.get('reason') or data.get('note') or '').strip()
try:
from core.agent_tools import answer_game_sound_card
from integrations.commerce.approvals import approver_from_request
_gs_body, _gs_code = answer_game_sound_card(
agent_id, action_raw, approved, reason,
approver_from_request(request))
return jsonify(_gs_body), _gs_code
except Exception as _gs_exc:
app.logger.warning(
f'agent_approval: game sound answer failed: {_gs_exc}')
return jsonify({'status': 'approved' if approved else 'rejected',
'action': action, 'applied': False,
'error': str(_gs_exc)}), 200
if not approved:
# Stage-C (Symptom #6): publish the deny event on WAMP too
# so subscribers (VisionService, UI) can tear down cleanly
# without inspecting HTTP responses.
try:
_cu_d = data.get('user_id') or data.get('userId') or 'local'
publish_async(
f'com.hertzai.hevolve.vision.{_cu_d}',
json.dumps({
'type': 'consent',
'user_id': _cu_d,
'agent_id': agent_id,
'action': action,
'decision': 'denied',
'ts': int(time.time()),
}),
)
except Exception as _pub_exc:
app.logger.debug(f"agent_approval deny WAMP publish failed: {_pub_exc}")
app.logger.info(f'agent_approval: agent={agent_id} action={action} DENIED')
return jsonify({'status': 'denied', 'action': action}), 200
# Map the approval to a feed toggle and reuse the single
# _apply_embodied_toggle codepath admin settings use.
feed = None
if action in ('enable_camera', 'camera', 'vision'):
feed = 'camera'
elif action in ('enable_screen', 'screen', 'computer_use'):
feed = 'screen'
elif action in ('enable_audio', 'audio', 'mic'):
feed = 'audio'
else:
# Unknown actions are accepted but do nothing — the agent
# may be asking for a capability we don't manage here.
app.logger.info(f'agent_approval: agent={agent_id} action={action} '
f'approved but no feed mapping — noop')
return jsonify({'status': 'approved', 'action': action, 'applied': False}), 200
try:
from integrations.channels.admin.api import (
get_api, apply_embodied_answer,
)
api = get_api()
cfg = api._global_config.embodied_ai
if _recorded is None:
# Either this capability records no consent ('audio' has no
# ask producer, so no consent type) or the write above
# failed. Apply it here so the owner's click still acts,
# exactly as it did before the migration. When _recorded is
# set, ConsentService already drove this same actuator from
# the grant — applying again here would be the second path.
#
# Flip the persisted flag so the next restart sees it and so
# get_embodied_status reports the right state.
if feed == 'camera':
cfg.camera_enabled = True
elif feed == 'screen':
cfg.screen_capture_enabled = True
elif feed == 'audio':
cfg.audio_enabled = True
api._save_config()
# The one way in: the capture gate now, the VisionService
# start on the feed's own worker, never on this request.
apply_embodied_answer(feed, True, cfg)
# Stage-C (Symptom #6, 2026-04-16) — publish the consent
# event on Crossbar WAMP so subscribers (VisionService,
# frontend, mobile) never have to poll or watch a raw WS
# for the decision. House-rule 6: 'ALL realtime push uses
# Crossbar WAMP'. Topic format matches the existing
# com.hertzai.hevolve.* convention.
try:
_cu = data.get('user_id') or data.get('userId') or 'local'
_consent_topic = f'com.hertzai.hevolve.vision.{_cu}'
_consent_payload = {
'type': 'consent',
'user_id': _cu,
'agent_id': agent_id,
'action': action,
'feed': feed,
'decision': 'approved',
'ts': int(time.time()),
}
publish_async(_consent_topic, json.dumps(_consent_payload))
app.logger.info(
f"agent_approval: WAMP consent published to "
f"{_consent_topic} feed={feed}"
)
except Exception as _pub_exc:
# Publish failure MUST NOT roll back the approval.
# The HTTP response still tells the caller the feed
# was applied — the WAMP channel is for notification,
# not authorization.
app.logger.debug(f"agent_approval WAMP publish failed: {_pub_exc}")
app.logger.info(f'agent_approval: agent={agent_id} action={action} '
f'APPROVED → feed={feed} started')
return jsonify({'status': 'approved', 'action': action,
'feed': feed, 'applied': True}), 200
except Exception as inner:
app.logger.warning(f'agent_approval: applied failed: {inner}')
return jsonify({'status': 'approved', 'action': action,
'applied': False, 'error': str(inner)[:200]}), 200
except Exception as e:
app.logger.error(f'agent_approval error: {e}')
return jsonify({'status': 'error', 'error': str(e)[:200]}), 500
@app.route('/add_history', methods=['POST'])
def history():
data = request.get_json()
human_msg = data['human_msg']
ai_msg = data['ai_msg']
try:
memory = get_memory(user_id=int(data['user_id']))
except Exception:
return "Invalid user ID"
if memory:
try:
memory.chat_memory.add_message(HumanMessage(content=human_msg))
memory.chat_memory.add_message(AIMessage(content=ai_msg))
return jsonify({'response': "Messages are saved!!!"}), 200
except Exception as e:
app.logger.warning(f"History not saved: {e}")
return jsonify({'response': "Messages not saved (memory service unavailable)"}), 503
else:
return jsonify({'response': "Memory object not found"}), 400
# ═══════════════════════════════════════════════════════════════
# Social Bridge: auto-create social user when agent is created via /chat
# ═══════════════════════════════════════════════════════════════
def _create_social_agent_from_prompt(user_id, prompt_id):
"""Read prompts/{prompt_id}.json and create a social User for this agent."""
prompt_file = os.path.join(PROMPTS_DIR, f'{prompt_id}.json')
if not os.path.exists(prompt_file):
return
with open(prompt_file, 'r') as f:
data = json.load(f)
agent_display_name = data.get('name', f'Agent {prompt_id}')
agent_name = data.get('agent_name', '') # 3-word name from LLM
goal = data.get('goal', '')
from integrations.social.models import get_db
from integrations.social.services import UserService
db = get_db()
try:
# Use LLM-generated 3-word name if available, otherwise generate one
if not agent_name:
from integrations.social.agent_naming import generate_agent_name
suggestions = generate_agent_name(db, count=1)
agent_name = suggestions[0] if suggestions else f"agent-{prompt_id}"
try:
user = UserService.register_agent(
db, agent_name, goal or agent_display_name,
agent_id=str(prompt_id), owner_id=str(user_id),
skip_name_validation=not bool(agent_name))
user.display_name = agent_display_name
db.flush()
except ValueError:
logging.getLogger(__name__).debug("_create_social_agent_from_prompt: swallowed ValueError", exc_info=True) # already exists
db.commit()
app.logger.info(f"Social agent created: {agent_name} for prompt {prompt_id}")
except Exception as e:
db.rollback()
app.logger.debug(f"Social bridge error: {e}")
finally:
db.close()
# ═══════════════════════════════════════════════════════════════
# Local-first Prompt CRUD (syncs to cloud DB)
# ═══════════════════════════════════════════════════════════════
# Media/presentation fields a colliding LOCAL record adopts from its cloud
# twin when it lacks them. Human-created agents share the SAME prompt_id in
# both stores (central-DB integer id; agent-sync writes {pid}.json under that
# id), so the local-first dedup used to DISCARD the cloud record wholesale —
# and with it the idle/intro filler videos that only exist cloud-side. Idle
# videos died agent-by-agent as agents localized ("idle videos not loading",
# 2026-06-10). Local stays authoritative for recipe/state fields.
_CLOUD_MEDIA_FIELDS = ('fillers', 'image_url', 'teacher_image_url',
'video_text')
def _merge_prompts_with_cloud(local_prompts: list, cloud_url: str) -> list:
"""Append cloud prompts to ``local_prompts``, dedup by prompt_id.
Local-first: when the same prompt_id exists in both, the local copy
is kept (it carries the newer recipe payload + filesystem-side
metadata) — but it ADOPTS the cloud twin's media fields
(``_CLOUD_MEDIA_FIELDS``) when it lacks them, so cloud-hosted idle
filler videos survive localization. Cloud failures are swallowed so
a flaky DB never breaks the local-only listing — same best-effort
contract both /prompts and /prompts/public have always offered.
Single source of truth for the merge so /prompts (user-scoped) and
/prompts/public (catalogue) cannot drift in dedup logic, error
handling, or the 'cloud'/'has_recipe' field defaults.
"""
by_id = {str(p.get('prompt_id', '')): p for p in local_prompts}
try:
res = pooled_get(cloud_url, timeout=5)
if res.status_code == 200:
for item in (res.json() or []):
_pid = str(item.get('prompt_id', ''))
local_twin = by_id.get(_pid)
if local_twin is not None:
# Graft cloud media onto the winning local record.
for _f in _CLOUD_MEDIA_FIELDS:
if not local_twin.get(_f) and item.get(_f):
local_twin[_f] = item[_f]
continue
item['source'] = 'cloud'
item.setdefault('has_recipe', False)
local_prompts.append(item)
except Exception:
logging.getLogger(__name__).exception("_merge_prompts_with_cloud: swallowed Exception")
return local_prompts
@app.route('/prompts', methods=['GET'])
def get_prompts():
"""List prompts for a user. Local files (filtered by creator) merged
with the user's cloud-side prompts via _merge_prompts_with_cloud."""
req_user_id = request.args.get('user_id', '')
if not req_user_id:
return jsonify({'error': 'user_id required'}), 400
prompts = []
# 1. Local agent records (core.prompt_files: agents only, never the plans
# the CREATE path stages beside them for review)
from core.prompt_files import local_agent_prompts
for pid, data in local_agent_prompts(PROMPTS_DIR):
try:
creator = str(data.get('creator_user_id', ''))
if creator == str(req_user_id) or not creator:
prompts.append({
'prompt_id': pid,
'name': data.get('name', ''),
'prompt': data.get('goal', ''),
'agent_name': data.get('agent_name', ''),
'is_active': data.get('status', '') == 'completed',
'user_id': creator or req_user_id,
'has_recipe': os.path.exists(
os.path.join(PROMPTS_DIR, f'{pid}_0_recipe.json')),
'flow_count': len(data.get('flows', [])),
'source': 'local',
# Media passthrough — /prompts/public already
# exposes image_url; the user-scoped list dropped
# it, so /local agents rendered an empty video
# column (no idle fallback portrait). fillers is
# future-proofing: local agents have none today
# (cloud-agent feature), but if cloud-sync ever
# writes them locally, idle videos light up with
# no further code change.
'image_url': data.get('image_url', ''),
'fillers': data.get('fillers') or [],
})
except Exception as e:
app.logger.debug(f'/prompts: skipped local agent {pid}: {e}')
# 2. Merge in cloud-only agents the user owns on hevolve.ai —
# the local store doesn't know about agents the user created from
# another device. Powers Nunba's "Hybrid" intelligence preference.
# MUST use CENTRAL_DB_URL not DB_URL — in bundled Nunba mode DB_URL
# collapses to http://localhost:5000 (this same Flask process), so
# merging would just hit our own /getprompt_onlyuserid handler and
# see the same local prompts again — silent no-op.
if CENTRAL_DB_URL:
_merge_prompts_with_cloud(
prompts,
f'{CENTRAL_DB_URL}/getprompt_onlyuserid/?user_id={req_user_id}')
return jsonify(prompts)
@app.route('/prompts/public', methods=['GET'])
def get_public_prompts():
"""Return all public prompts/agents. Local-first, cloud-merge.
Equivalent to the legacy /getprompt_all/ cloud endpoint."""
prompts = []
# 1. ALL local agent records, no user filter (core.prompt_files: agents
# only, never the plans the CREATE path stages beside them for review)
from core.prompt_files import local_agent_prompts
for pid, data in local_agent_prompts(PROMPTS_DIR):
try:
prompts.append({
'prompt_id': pid,
'name': data.get('name', ''),
'prompt': data.get('goal', ''),
'agent_name': data.get('agent_name', ''),
'is_active': data.get('status', '') == 'completed',
'is_public': True,
'user_id': data.get('creator_user_id', ''),
'teacher_image_url': data.get('teacher_image_url', ''),
'image_url': data.get('image_url', ''),
'video_text': data.get('video_text', ''),
'has_recipe': os.path.exists(
os.path.join(PROMPTS_DIR, f'{pid}_0_recipe.json')),
'flow_count': len(data.get('flows', [])),
'source': 'local',
})
except Exception as e:
app.logger.debug(f'/prompts/public: skipped local agent {pid}: {e}')
# 2. Merge in cloud-only public agents. Same central-vs-local-DB_URL
# rationale as /prompts above — DB_URL is localhost in bundled mode
# and would no-op the merge.
if CENTRAL_DB_URL:
_merge_prompts_with_cloud(
prompts, f'{CENTRAL_DB_URL}/getprompt_all/')
return jsonify(prompts)
@app.route('/api/admin/agent-data//info', methods=['GET'])
def admin_agent_data_info(prompt_id):
"""Diagnostic info for an agent's persisted data file.
Returns size, last-modified, saved_at metadata, and the top-level
data keys so the admin UI can show disk footprint per agent and
decide when to prune stale agents. Wraps helper.get_agent_data_info
— the info-gathering stays in helper where the file-layout logic
lives; this route is a thin JSON facade."""
try:
from hartos.helper import get_agent_data_info as _info
except ImportError:
return jsonify({'error': 'helper.get_agent_data_info unavailable'}), 500
return jsonify(_info(int(prompt_id)))
_prompt_id_lock = threading.Lock()
def _next_prompt_id():
"""Generate a globally-unique prompt_id.
Uses millisecond timestamp so IDs are unique across nodes in the
federation (no central coordinator needed). A local collision check
ensures two agents created on the same node within the same
millisecond still get distinct IDs.
Thread-safe via lock.
NOTE: Returns 1–11 digit numeric IDs by folding the millisecond
timestamp into a max-11-digit space.
"""
with _prompt_id_lock:
# Fold ms timestamp into 1..99,999,999,999 (1–11 digits)
pid = int(time.time() * 1000) % 100_000_000_000
if pid == 0:
pid = 1
if os.path.isdir(PROMPTS_DIR):
# Preserve the same collision-avoidance logic
while os.path.exists(os.path.join(PROMPTS_DIR, f'{pid}.json')):
pid += 1
# Keep pid within 1–11 digits while still allowing bumps
if pid >= 100_000_000_000:
pid = 1
return pid
@app.route('/prompts', methods=['POST'])
def create_prompts():
"""Create/update prompts. Saves locally AND syncs to cloud DB."""
data = request.get_json()
listprompts = data.get('listprompts', [data] if 'name' in data else [])
saved = []
for item in listprompts:
pid = item.get('prompt_id')
if not pid:
pid = _next_prompt_id()
item['prompt_id'] = pid
# Save locally
local_path = os.path.join(PROMPTS_DIR, f'{pid}.json')
local_data = {}
if os.path.exists(local_path):
with open(local_path, 'r') as f:
local_data = json.load(f)
local_data['name'] = item.get('name', local_data.get('name', ''))
local_data['goal'] = item.get('prompt', item.get('goal', local_data.get('goal', '')))
local_data['prompt_id'] = pid
local_data['creator_user_id'] = item.get('user_id', local_data.get('creator_user_id'))
if 'agent_name' in item:
local_data['agent_name'] = item['agent_name']
if 'status' not in local_data:
local_data['status'] = 'pending'
with open(local_path, 'w') as f:
json.dump(local_data, f, indent=2)
saved.append({'prompt_id': pid, 'name': local_data['name']})
# Sync to cloud DB (non-blocking, best effort)
try:
pooled_post(
f'{DB_URL}/createpromptlist',
json={'listprompts': listprompts},
timeout=5)
except Exception as e:
app.logger.debug(f"Cloud sync failed (non-fatal): {e}")
return jsonify({'success': True, 'saved': saved})
# ───────────────────────── Recipe sync endpoints ─────────────────────
# Cross-device sync for recipe-file BUNDLES (the {id}.json + flow +
# personality + per-action recipes), distinct from /createpromptlist
# which only syncs metadata. See core/recipe_sync.py for the
# canonical wire envelope. Closes the cross-device gap that caused
# the 2026-05-04 Speech Therapy silent fallback.
# In-process recipe blob store (cloud tier installs override via
# DB-backed implementation; flat installs fine with on-disk).
_RECIPE_BLOB_DIR = os.path.join(PROMPTS_DIR, '..', 'recipe_blobs')
@app.route('/prompts/sync', methods=['POST'])
def upload_recipe_bundle():
"""POST a recipe bundle for one prompt_id.
Body shape: see core.recipe_sync.build_envelope. The cloud
deployment of HARTOS persists this; flat installs may also
accept it for testing but the file is intended to round-trip
via the central instance.
Defenses (added in post-shipment hardening pass):
M5 - prompt_id MUST equal os.path.basename(prompt_id) so an
attacker can't write to ../../etc/passwd via the path
construction at the bottom of this handler
M6 - reject when incoming uploaded_at < existing uploaded_at
UNLESS body carries force=True. Prevents an older client
from silently clobbering a newer cloud state. Idempotent
on identical-checksum re-pushes (200 OK without rewrite)
"""
try:
from core.recipe_sync import SCHEMA_VERSION, _safe_filename
except ImportError:
return jsonify({'error': 'recipe_sync unavailable'}), 503
envelope = request.get_json() or {}
if envelope.get('schema_version') != SCHEMA_VERSION:
return jsonify({
'error': 'schema_mismatch',
'expected': SCHEMA_VERSION,
'got': envelope.get('schema_version'),
}), 400
prompt_id = envelope.get('prompt_id')
files = envelope.get('files') or {}
if not prompt_id or not files:
return jsonify({'error': 'prompt_id + files required'}), 400
# M5: prompt_id flows into a filesystem path - validate it the
# same way the recipe_sync.pull_recipe path validates filenames.
pid_str = str(prompt_id)
if not _safe_filename(pid_str + '.json'):
return jsonify({
'error': 'unsafe prompt_id - must be basename-safe',
'prompt_id': pid_str,
}), 400
try:
os.makedirs(_RECIPE_BLOB_DIR, exist_ok=True)
except OSError as e:
return jsonify({'error': f'blob dir create failed: {e}'}), 500
blob_path = os.path.join(_RECIPE_BLOB_DIR, f'{pid_str}.json')
# M6: staleness guard - reject if incoming envelope is older than
# what's on disk, unless caller explicitly opts into overwrite.
# The 'force' query param is a deliberate, explicit override; the
# body field exists for symmetry.
force = bool(envelope.get('force')) or \
request.args.get('force', '').lower() in ('1', 'true', 'yes')
incoming_ts = int(envelope.get('uploaded_at') or 0)
incoming_checksum = envelope.get('checksum', '')
if os.path.isfile(blob_path):
try:
with open(blob_path, 'r', encoding='utf-8') as f:
existing = json.load(f)
existing_ts = int(existing.get('uploaded_at') or 0)
existing_checksum = existing.get('checksum', '')
# Idempotent re-push: same checksum → 200 OK without rewrite
if incoming_checksum and incoming_checksum == existing_checksum:
return jsonify({
'stored': True, 'unchanged': True,
'checksum': incoming_checksum,
'files_count': len(files),
})
if not force and incoming_ts < existing_ts:
return jsonify({
'error': 'stale_upload',
'reason': (
f'incoming uploaded_at={incoming_ts} < '
f'existing={existing_ts}; pass force=true to '
f'override (will silently overwrite newer cloud state)'),
'existing_checksum': existing_checksum,
}), 409 # Conflict
except (IOError, OSError, json.JSONDecodeError, ValueError) as e:
app.logger.debug(
f'/prompts/sync staleness check failed for {pid_str}: {e}')
# Fall through and write - existing blob is corrupt anyway
# Atomic write so concurrent GET can't read a half-written blob.
tmp_path = blob_path + '.tmp'
try:
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(envelope, f)
os.replace(tmp_path, blob_path)
except (IOError, OSError) as e:
try:
os.remove(tmp_path)
except OSError:
logging.getLogger(__name__).warning("upload_recipe_bundle: swallowed OSError", exc_info=True)
return jsonify({'error': f'blob write failed: {e}'}), 500
return jsonify({
'stored': True,
'checksum': envelope.get('checksum', ''),
'files_count': len(files),
})
@app.route('/prompts/sync/', methods=['GET'])
def download_recipe_bundle(prompt_id):
"""GET the recipe bundle for *prompt_id*.
Returns 404 when no blob has been uploaded yet. Cloud tier
installs serve from a DB-backed store; flat installs read from
the on-disk blob dir written by upload_recipe_bundle.
Defense (post-shipment hardening, mirrors upload_recipe_bundle's
M5 check): prompt_id flows from the URL path into a filesystem
join. Flask's default ``string`` URL converter forbids ``/`` but
permits ``..`` after URL-decode, so a request like
``GET /prompts/sync/..%2F..%2Fetc%2Fpasswd`` would otherwise
resolve outside _RECIPE_BLOB_DIR. We validate the same way the
upload path does — via core.recipe_sync._safe_filename — and add
a realpath containment check as belt+suspenders.
"""
try:
from core.recipe_sync import _safe_filename
except ImportError:
return jsonify({'error': 'recipe_sync unavailable'}), 503
pid_str = str(prompt_id)
if not _safe_filename(pid_str + '.json'):
return jsonify({
'error': 'unsafe prompt_id - must be basename-safe',
}), 400
blob_path = os.path.join(_RECIPE_BLOB_DIR, f'{pid_str}.json')
# Realpath containment: even if _safe_filename misses some
# platform-specific edge case (symlink dir, NTFS short name),
# this catch-all keeps reads inside the blob dir.
blob_real = os.path.realpath(blob_path)
base_real = os.path.realpath(_RECIPE_BLOB_DIR)
if not (blob_real == base_real
or blob_real.startswith(base_real + os.sep)):
return jsonify({'error': 'unsafe prompt_id'}), 400
if not os.path.isfile(blob_path):
return jsonify({'error': 'not_found'}), 404
try:
with open(blob_path, 'r', encoding='utf-8') as f:
envelope = json.load(f)
except (IOError, OSError, json.JSONDecodeError) as e:
return jsonify({'error': f'blob read failed: {e}'}), 500
return jsonify(envelope)
def _get_active_backend_info() -> dict:
"""Get which LLM backend is currently serving inference: the decision
core.autogen_config makes from the configured trio, taken fresh so the
status reflects a hot-reloaded vault export."""
tier = os.environ.get('HEVOLVE_NODE_TIER', 'flat')
kind, entry = resolve_llm_backend()
if kind == 'api':
return {
'type': 'external',
'display_name': f"External ({entry['model'] or 'unknown'})",
'model': entry['model'],
'url': entry.get('base_url', ''),
'mode': tier,
}
cloud_url = os.environ.get('HEVOLVE_CLOUD_FALLBACK_URL', '')
return {
'type': 'local_llamacpp',
'display_name': 'llama.cpp (Nunba)',
'model': entry['model'],
'mode': tier,
'cloud_fallback_configured': bool(cloud_url),
}
@app.route('/status', methods=['GET'])
def status():
result = {'response': 'Working...', 'status': 'running'}
# Active LLM backend info
result['llm_backend'] = _get_active_backend_info()
result['node_tier'] = os.environ.get('HEVOLVE_NODE_TIER', 'flat')
# HevolveAI health (non-blocking, fail-safe)
try:
from integrations.agent_engine.world_model_bridge import get_world_model_bridge
bridge = get_world_model_bridge()
bridge_stats = bridge.get_stats()
result['hevolve_core_url'] = bridge_stats.get('api_url', '')
result['in_process'] = bridge_stats.get('in_process', False)
health = bridge.check_health()
result['hevolve_core_healthy'] = health.get('healthy', False)
result['learning_active'] = health.get('learning_active', False)
result['learning_mode'] = health.get('mode', 'unknown')
if not result['hevolve_core_healthy']:
# UNHEALTHY WITHOUT AN EXCEPTION is the common case, and the
# earlier fix only covered the raising one — so a VM reported
# hevolve_core_healthy:false, learning_mode:"disabled" and NO
# reason at all (run 30848154453, hart-server-boot). From outside,
# a deliberately-disabled core, a core answering 503, and a core
# that is not there look identical.
#
# check_health already carries the reason in every unhealthy
# branch; /status was discarding it. Read from it rather than
# inventing a second vocabulary: `details.error` for a transport
# failure, `details.status_code` for a bad answer, and mode
# 'disabled' for the bundled no-server case.
details = health.get('details') or {}
if details.get('error'):
why = f"core unreachable: {details['error']}"
elif details.get('status_code'):
why = (f"core /health answered HTTP {details['status_code']}")
elif result['learning_mode'] == 'disabled':
why = ('learning HTTP bridge is disabled — bundled with no '
'core server to talk to')
else:
why = (f"core reported unhealthy (mode="
f"{result['learning_mode']!r}) with no further detail")
result['hevolve_core_error'] = why
except Exception as exc:
# WHY THIS LOGS NOW (task #3d): when health is False the single fact a
# reader needs is WHY, and this handler was throwing it away. The
# failure it swallows is not hypothetical — VM run 30758875130 shows
# the learning pipeline failing to import (hevolveai rl_ef ->
# "RuntimeError: Explicitly using 'asyncio' already"), and from the
# outside that was indistinguishable from a healthy node that simply
# is not learning yet.
app.logger.warning(
"status: hevolve bridge health check failed (%s: %s) — reporting "
"core unhealthy + learning inactive",
type(exc).__name__, exc, exc_info=True)
result['hevolve_core_healthy'] = False
result['learning_active'] = False
# Keep the response SHAPE stable. The success path always sets
# learning_mode; omitting it here made the degraded response quietly
# missing a key, and hart_cli's `status` command prints whatever keys
# it finds — so the field simply vanished exactly when it mattered.
result['learning_mode'] = 'unknown'
result['hevolve_core_error'] = f"{type(exc).__name__}: {exc}"
return jsonify(result)
@app.route('/health', methods=['GET'])
def health_liveness():
"""Liveness probe — returns 200 if the process is running.
Use for K8s livenessProbe / systemd WatchdogSec.
"""
return jsonify({'status': 'alive'}), 200
@app.route('/ready', methods=['GET'])
def health_readiness():
"""Readiness probe — returns 200 only when critical subsystems are healthy.
Checks:
- Database: SELECT 1 via get_db()
- Node identity: get_node_identity() returns non-None
Returns 503 when any critical check fails.
"""
checks = {}
all_ok = True
# Check 1: Database connectivity
try:
from integrations.social.models import get_db
from sqlalchemy import text as _sa_text
db = get_db()
try:
db.execute(_sa_text('SELECT 1'))
checks['database'] = 'ok'
finally:
db.close()
except Exception as e:
checks['database'] = f'fail: {e}'
all_ok = False
# Check 2: Node identity
try:
from security.node_integrity import get_node_identity
identity = get_node_identity()
if identity:
checks['node_identity'] = 'ok'
else:
checks['node_identity'] = 'fail: no identity'
all_ok = False
except Exception as e:
checks['node_identity'] = f'fail: {e}'
all_ok = False
# Check 3: HevolveAI bridge (optional — not critical)
try:
from integrations.agent_engine.world_model_bridge import get_world_model_bridge
bridge = get_world_model_bridge()
bridge_health = bridge.check_health()
checks['hevolve_core'] = 'ok' if bridge_health.get('healthy') else 'degraded'
except Exception:
checks['hevolve_core'] = 'unavailable'
# Check 4: LLM backend (optional — not critical)
try:
backend_info = _get_active_backend_info()
checks['llm_backend'] = backend_info.get('backend', 'unknown')
except Exception:
checks['llm_backend'] = 'unavailable'
# Check 5: Vision frame store (optional — reports nothing if vision is off)
try:
fs = get_frame_store()
if fs is None:
checks['vision_frame_store'] = 'unavailable'
else:
stats = fs.stats() if hasattr(fs, 'stats') else {}
checks['vision_frame_store'] = stats or 'ok'
except Exception as e:
checks['vision_frame_store'] = f'fail: {e}'
# Check 6: Diarization sidecar (optional — reports nothing if disabled)
try:
diar = get_diarization_service()
if diar is None:
checks['diarization'] = 'unavailable'
else:
checks['diarization'] = 'ready' if diar.is_ready() else 'starting'
except Exception as e:
checks['diarization'] = f'fail: {e}'
status_code = 200 if all_ok else 503
return jsonify({
'status': 'ready' if all_ok else 'not_ready',
'checks': checks,
}), status_code
# ============================================================================
# HART Challenge-Response Endpoint (Node Side)
# ============================================================================
# Central delivers a challenge nonce to this endpoint to prove the node is
# reachable at its claimed FQDN. The node signs the nonce with its Ed25519
# private key and returns the signature. This is step 2+3 of the 4-step
# domain verification handshake defined in security/key_delegation.py.
#
# Security notes:
# - This endpoint is intentionally unauthenticated: central must be able to
# reach it without prior credentials to prove domain reachability.
# - The nonce is signed with the node's private key, which never leaves the
# node. Central verifies using the public key the node provided at
# registration time.
# - The endpoint only responds to GET (challenge delivery) and POST
# (explicit challenge-response submission from the node itself if needed).
# Module-level storage for the most recent challenge nonce received from
# central. The node-side orchestrator can poll this to retrieve the nonce
# and submit the signed response back to central.
_hart_challenge_state = {
'lock': threading.Lock(),
'pending_nonce': None, # str | None — most recent nonce from central
'pending_signature': None, # str | None — signature once computed
'pending_pubkey': None, # str | None — this node's public key hex
'received_at': None, # datetime | None
}
@app.route('/.well-known/hart-challenge', methods=['GET'])
def hart_challenge_receive():
"""Receive a challenge nonce from central and auto-sign it.
Central calls: GET http://{fqdn}:6777/.well-known/hart-challenge?nonce=
The node:
1. Validates the nonce format (must be 64 hex chars = 32 bytes).
2. Signs the raw nonce bytes with its Ed25519 private key.
3. Returns {nonce, public_key_hex, signature_hex} so central can
immediately verify without a second round-trip.
Returns 200 with signed response on success, 400 on bad input.
"""
nonce_hex = request.args.get('nonce', '').strip()
if not nonce_hex:
return jsonify({'error': 'Missing "nonce" query parameter'}), 400
# Validate nonce format: must be valid hex and exactly 32 bytes (64 chars)
try:
nonce_bytes = bytes.fromhex(nonce_hex)
if len(nonce_bytes) != 32:
return jsonify({
'error': f'Invalid nonce length: expected 32 bytes (64 hex chars), '
f'got {len(nonce_bytes)} bytes',
}), 400
except ValueError:
return jsonify({'error': 'Invalid nonce: not valid hexadecimal'}), 400
# Sign the raw nonce bytes with this node's Ed25519 private key
try:
from security.node_integrity import sign_message, get_public_key_hex
signature = sign_message(nonce_bytes)
public_key_hex = get_public_key_hex()
except Exception as e:
app.logger.error(f"hart-challenge: failed to sign nonce: {e}")
return jsonify({'error': 'Internal error: failed to sign challenge'}), 500
signature_hex = signature.hex()
# Store in module state for potential polling by node-side orchestrator
import datetime as _dt_mod
with _hart_challenge_state['lock']:
_hart_challenge_state['pending_nonce'] = nonce_hex
_hart_challenge_state['pending_signature'] = signature_hex
_hart_challenge_state['pending_pubkey'] = public_key_hex
_hart_challenge_state['received_at'] = _dt_mod.datetime.now(_dt_mod.timezone.utc)
app.logger.info(
f"hart-challenge: signed nonce {nonce_hex[:16]}... "
f"pubkey={public_key_hex[:16]}...")
return jsonify({
'nonce': nonce_hex,
'public_key_hex': public_key_hex,
'signature_hex': signature_hex,
}), 200
@app.route('/.well-known/hart-challenge', methods=['POST'])
def hart_challenge_submit():
"""Accept an explicit challenge-response submission.
An alternative path where the node operator or automation explicitly
submits {nonce, fqdn} and this endpoint signs and returns the response.
Useful when the node needs to relay the signed response to central
through a different channel.
Request JSON: {"nonce": ""}
Response JSON: {"nonce": "...", "public_key_hex": "...", "signature_hex": "..."}
"""
data = request.get_json(silent=True) or {}
nonce_hex = data.get('nonce', '').strip()
if not nonce_hex:
return jsonify({'error': 'Missing "nonce" in request body'}), 400
try:
nonce_bytes = bytes.fromhex(nonce_hex)
if len(nonce_bytes) != 32:
return jsonify({
'error': f'Invalid nonce length: expected 32 bytes, got {len(nonce_bytes)}',
}), 400
except ValueError:
return jsonify({'error': 'Invalid nonce: not valid hexadecimal'}), 400
try:
from security.node_integrity import sign_message, get_public_key_hex
signature = sign_message(nonce_bytes)
public_key_hex = get_public_key_hex()
except Exception as e:
app.logger.error(f"hart-challenge POST: failed to sign nonce: {e}")
return jsonify({'error': 'Internal error: failed to sign challenge'}), 500
signature_hex = signature.hex()
app.logger.info(
f"hart-challenge POST: signed nonce {nonce_hex[:16]}... "
f"pubkey={public_key_hex[:16]}...")
return jsonify({
'nonce': nonce_hex,
'public_key_hex': public_key_hex,
'signature_hex': signature_hex,
}), 200
@app.route('/zeroshot/', methods=['POST'])
def zeroshot():
"""
Zero-shot classification endpoint using GPT-4.1-mini model.
Request JSON format:
{
"input_text": "text to classify",
"labels": ["label1", "label2", "label3"],
"multi_label": false // optional, default is false
}
Response format:
{
"sequence": "input text",
"labels": ["label1", "label2", "label3"],
"scores": [0.85, 0.10, 0.05]
}
"""
try:
# Get request data
data = request.get_json(force=True)
input_text = data.get('input_text', '')
labels = data.get('labels', [])
multi_label = data.get('multi_label', False)
# Validate inputs
if not input_text:
return jsonify({"error": "input_text is required"}), 400
if not labels or len(labels) == 0:
return jsonify({"error": "labels list is required and cannot be empty"}), 400
# Create prompt for zero-shot classification
if multi_label:
prompt = f"""You are a text classification system. Given the following text and a list of labels, determine which labels apply to the text. Multiple labels can apply.
Text: "{input_text}"
Available labels: {', '.join(labels)}
For each label, provide a confidence score between 0 and 1 indicating how well it applies to the text. The scores don't need to sum to 1.
Respond ONLY with a JSON object in this exact format:
{{"scores": {{"label1": score1, "label2": score2, ...}}}}
Example response format:
{{"scores": {{"sports": 0.85, "entertainment": 0.60, "politics": 0.15}}}}"""
else:
prompt = f"""You are a text classification system. Given the following text and a list of labels, classify the text into ONE of the provided labels.
Text: "{input_text}"
Available labels: {', '.join(labels)}
Provide a confidence score between 0 and 1 for each label. The scores should sum to approximately 1.0, with the highest score indicating the most likely label.
Respond ONLY with a JSON object in this exact format:
{{"scores": {{"label1": score1, "label2": score2, ...}}}}
Example response format:
{{"scores": {{"sports": 0.75, "entertainment": 0.15, "politics": 0.10}}}}"""
app.logger.info(f"Zero-shot classification request - Text: {input_text[:100]}..., Labels: {labels}")
# Call the configured LLM
response = pooled_post(
GPT_API,
json={
"model": LLM_MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 500
},
headers=LLM_AUTH_HEADERS,
)
app.logger.info(f"GPT API response status: {response.status_code}")
if response.status_code != 200:
return jsonify({"error": "GPT API request failed", "details": response.text}), 500
# Parse GPT response
gpt_result = response.json()
app.logger.info(f"GPT response: {gpt_result}")
# Extract the response content
if isinstance(gpt_result, dict) and 'text' in gpt_result:
response_text = gpt_result['text']
elif isinstance(gpt_result, dict) and 'response' in gpt_result:
response_text = gpt_result['response']
elif isinstance(gpt_result, dict) and 'choices' in gpt_result:
response_text = gpt_result['choices'][0]['message']['content']
else:
response_text = str(gpt_result)
app.logger.info(f"Response text: {response_text}")
# Parse JSON from response
try:
# Try to find JSON in the response
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
if json_match:
parsed_response = json.loads(json_match.group())
else:
parsed_response = json.loads(response_text)
scores_dict = parsed_response.get('scores', {})
# Convert to list format sorted by scores (descending)
sorted_items = sorted(scores_dict.items(), key=lambda x: x[1], reverse=True)
sorted_labels = [item[0] for item in sorted_items]
sorted_scores = [item[1] for item in sorted_items]
# Build response in format similar to transformers zero-shot-classification
result = {
"sequence": input_text,
"labels": sorted_labels,
"scores": sorted_scores
}
app.logger.info(f"Final result: {result}")
return jsonify(result)
except json.JSONDecodeError as e:
app.logger.error(f"JSON parsing error: {e}, Response: {response_text}")
# Fallback: return equal probabilities if parsing fails
equal_score = 1.0 / len(labels)
return jsonify({
"sequence": input_text,
"labels": labels,
"scores": [equal_score] * len(labels),
"warning": "Could not parse model response, returning equal probabilities"
})
except Exception as e:
app.logger.error(f"Error in /zeroshot/ endpoint: {str(e)}")
return jsonify({"error": str(e)}), 500
# ─── Runtime Media Tools API ──────────────────────────────────────────
# Endpoints for managing runtime media tools (Wan2GP, TTS-Audio-Suite,
# Whisper, OmniParser). Tools are downloaded, started, and registered
# dynamically. See integrations/service_tools/runtime_manager.py.
@app.route('/api/tools/status', methods=['GET'])
@_json_endpoint
def tools_status():
"""Get status of all runtime media tools."""
from integrations.service_tools.runtime_manager import runtime_tool_manager
return jsonify(runtime_tool_manager.get_all_status())
@app.route('/api/tools//setup', methods=['POST'])
@_json_endpoint
def tools_setup(tool_name):
"""Download + start + register a runtime tool."""
from integrations.service_tools.runtime_manager import runtime_tool_manager
result = runtime_tool_manager.setup_tool(tool_name)
code = 500 if 'error' in result else 200
return jsonify(result), code
@app.route('/api/tools//start', methods=['POST'])
@_json_endpoint
def tools_start(tool_name):
"""Start an already-downloaded runtime tool."""
from integrations.service_tools.runtime_manager import runtime_tool_manager
result = runtime_tool_manager.start_tool(tool_name)
code = 500 if 'error' in result else 200
return jsonify(result), code
@app.route('/api/tools//stop', methods=['POST'])
@_json_endpoint
def tools_stop(tool_name):
"""Stop a running runtime tool and free VRAM."""
from integrations.service_tools.runtime_manager import runtime_tool_manager
return jsonify(runtime_tool_manager.stop_tool(tool_name))
@app.route('/api/tools//unload', methods=['POST'])
@_json_endpoint
def tools_unload(tool_name):
"""Stop + deregister a runtime tool."""
from integrations.service_tools.runtime_manager import runtime_tool_manager
return jsonify(runtime_tool_manager.unload_tool(tool_name))
@app.route('/api/tools/vram', methods=['GET'])
@_json_endpoint
def tools_vram():
"""Get VRAM usage dashboard."""
from integrations.service_tools.vram_manager import vram_manager
return jsonify(vram_manager.get_status())
# ─── Model Lifecycle API ─────────────────────────────────────────────
# Agentic model lifecycle management — dynamic load/unload/offload.
@app.route('/api/tools/lifecycle', methods=['GET'])
@_json_endpoint
def tools_lifecycle():
"""Model lifecycle dashboard: loaded models, priorities, VRAM pressure, hive hints."""
from integrations.service_tools.model_lifecycle import get_model_lifecycle_manager
mgr = get_model_lifecycle_manager()
return jsonify(mgr.get_status())
@app.route('/api/tools/lifecycle//priority', methods=['POST'])
@_json_endpoint
def tools_lifecycle_priority(model_name):
"""Manually set model priority (admin override)."""
from integrations.service_tools.model_lifecycle import get_model_lifecycle_manager
data = request.get_json() or {}
priority = data.get('priority', 'warm')
mgr = get_model_lifecycle_manager()
return jsonify(mgr.set_priority(model_name, priority))
@app.route('/api/tools/lifecycle//offload', methods=['POST'])
@_json_endpoint
def tools_lifecycle_offload(model_name):
"""Manually trigger GPU→CPU offload for a model."""
from integrations.service_tools.model_lifecycle import get_model_lifecycle_manager
mgr = get_model_lifecycle_manager()
return jsonify(mgr.manual_offload(model_name))
@app.route('/api/system/pressure', methods=['GET'])
@_json_endpoint
def system_pressure():
"""Real-time system pressure dashboard: VRAM, RAM, CPU, disk, throttle factor."""
from integrations.service_tools.model_lifecycle import get_model_lifecycle_manager
mgr = get_model_lifecycle_manager()
return jsonify(mgr.get_system_pressure())
@app.route('/api/revenue/dashboard', methods=['GET'])
@_json_endpoint
def revenue_dashboard():
"""Revenue pipeline dashboard: streams, trading P&L, compute borrowing."""
from integrations.agent_engine.revenue_aggregator import get_revenue_aggregator
from integrations.agent_engine.compute_borrowing import ComputeBorrowingService
from integrations.social.models import get_db
db = get_db()
try:
rev = get_revenue_aggregator()
dashboard = rev.get_dashboard(db)
dashboard['compute_borrowing'] = ComputeBorrowingService.get_status(db)
return jsonify(dashboard)
finally:
db.close()
# ─── Coding Agent Aggregator API ──────────────────────────────────────
# Endpoints for the coding tool orchestrator (KiloCode, Claude Code, OpenCode).
# Tools execute as external CLI subprocesses — never re-dispatch to /chat.
@app.route('/coding/tools', methods=['GET'])
@_json_endpoint
def coding_tools():
"""List installed coding tools, capabilities, and benchmarks."""
from integrations.coding_agent.orchestrator import get_coding_orchestrator
return jsonify(get_coding_orchestrator().list_tools())
@app.route('/coding/execute', methods=['POST'])
@_json_endpoint
def coding_execute():
"""Execute a coding task via the best available tool.
JSON body: {task, task_type?, preferred_tool?, model?, working_dir?}
If 'encrypted' key present, decrypts E2E envelope first (hive offload).
"""
data = request.get_json(force=True)
# Handle encrypted envelope from hive peer
encrypted = data.get('encrypted')
if encrypted:
try:
from security.channel_encryption import (
decrypt_json_from_peer, encrypt_json_for_peer,
get_x25519_public_hex,
)
data = decrypt_json_from_peer(encrypted)
if not data:
return jsonify({'error': 'Decryption failed'}), 400
except Exception as e:
return jsonify({'error': f'Envelope decryption error: {e}'}), 400
task = data.get('task', '')
if not task:
return jsonify({'error': 'task is required'}), 400
from integrations.coding_agent.orchestrator import get_coding_orchestrator
from hartos.threadlocal import thread_local_data
# Detached: this request thread may still carry the last /chat turn's
# prompt, user and activity run; the run then worked in that agent's goal
# repo and was announced as that user (review of cd99c540d). And an HTTP
# caller cannot be held for the owner's answer (it held one 108.6 s, past
# the 30-60 s its clients wait): the owner is asked, the caller told so.
with thread_local_data.detached():
result = get_coding_orchestrator().execute(
task=task,
task_type=data.get('task_type', 'feature'),
preferred_tool=data.get('preferred_tool', ''),
user_id=data.get('user_id', ''),
model=data.get('model', ''),
working_dir=data.get('working_dir', ''),
wait_for_owner=False,
)
# If request came from hive peer, encrypt the response back
if encrypted:
try:
from security.channel_encryption import encrypt_json_for_peer
peer_pub = data.get('_reply_x25519', '')
if peer_pub:
return jsonify({'encrypted': encrypt_json_for_peer(result, peer_pub)})
except Exception:
logging.getLogger(__name__).exception("coding_execute: swallowed Exception")
return jsonify(result)
@app.route('/coding/benchmarks', methods=['GET'])
@_json_endpoint
def coding_benchmarks():
"""Get coding tool benchmark dashboard data."""
from integrations.coding_agent.orchestrator import get_coding_orchestrator
return jsonify(get_coding_orchestrator().get_benchmarks())
@app.route('/coding/install', methods=['POST'])
@_json_endpoint
def coding_install():
"""Install a coding tool (npm, pip, or the official-script command for
the user to run — installer.install_tool picks). JSON body: {tool_name}"""
data = request.get_json(force=True)
tool_name = data.get('tool_name', '')
if not tool_name:
return jsonify({'error': 'tool_name is required'}), 400
from integrations.coding_agent.installer import install_tool
result = install_tool(tool_name)
code = 200 if result.get('success') else 500
return jsonify(result), code
@app.route('/api/voice/transcribe', methods=['POST'])
def voice_transcribe():
"""Transcribe audio to text using Whisper STT.
multipart/form-data: 'audio' (the recording), optional 'language'.
The audio is the upload, never a path. A JSON 'audio_path' made any
caller this node admits -- a consented phone on a desktop, any user on
central -- able to have any file on the node read and transcribed, and a
UNC path made it dial out (review of Nunba b07e025f, 2026-10-03). Same
rule as /api/voice/speak's voice/output_path (#67). In-process callers
use whisper_transcribe(path) directly.
"""
if not (request.content_type and 'multipart' in request.content_type):
return jsonify({'error': "upload the audio as multipart field 'audio'"}), 400
try:
from integrations.service_tools.whisper_tool import whisper_transcribe
import tempfile
import json as _json
audio_file = request.files.get('audio')
if not audio_file:
return jsonify({'error': 'No audio file provided'}), 400
language = request.form.get('language') or None
# Save to temp file
suffix = os.path.splitext(audio_file.filename or '')[1] or '.wav'
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
audio_file.save(tmp)
tmp_path = tmp.name
try:
result = whisper_transcribe(tmp_path, language)
return jsonify(_json.loads(result))
finally:
os.unlink(tmp_path)
except ImportError as e:
return jsonify({'error': f'Whisper not available: {e}'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
_SAFE_VOICE_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
def _safe_voice_ref(voice):
"""Confine the /api/voice/speak ``voice`` param to a saved-voice NAME (#67).
TTSRouter treats ``voice`` as a path-OR-name and clone-capable engines READ
it as a reference-audio file, so an unauth local caller passing an absolute
path or a traversal would read an arbitrary file. A saved voice name has
no path separators and no ``..``; anything else is dropped to None (the
engine's default voice). Internal callers that legitimately pass a
resolved path call TTSRouter.synthesize directly in Python, not through
this HTTP route, so confining the HTTP surface does not affect them.
"""
if not voice or not isinstance(voice, str):
return None
name = voice.strip()
if '..' in name or not _SAFE_VOICE_NAME.match(name):
return None
return name
#: A voice sample to CLONE from is read and copied into the voices dir, so an
#: unauth caller could read an arbitrary file. Require an audio extension and
#: no traversal so the cloner reads only audio the caller points at, not an
#: arbitrary sensitive file (a bundled desktop trusts its own local callers,
#: which the network gate does not cover) (#67).
_VOICE_SAMPLE_EXTS = ('.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac', '.webm')
#: A clone SAVE name is joined into the voices dir as ``.wav``; a name
#: with a path separator or ``..`` would write outside it. Spaces and case are
#: fine (the clone tool lowercases and hyphenates).
_SAFE_CLONE_NAME = re.compile(r'^[A-Za-z0-9 ._-]{1,64}$')
def _safe_sample_path(audio_path):
"""The clone reference-audio path, or None if it is not a safe audio file."""
if not audio_path or not isinstance(audio_path, str):
return None
path = audio_path.strip()
if not path or '..' in path:
return None
if not path.lower().endswith(_VOICE_SAMPLE_EXTS):
return None
return path
def _safe_clone_name(name):
"""The clone save-name, or None if it has a path separator / traversal."""
if not name or not isinstance(name, str):
return None
clean = name.strip()
if '..' in clean or not _SAFE_CLONE_NAME.match(clean):
return None
return clean
@app.route('/api/voice/speak', methods=['POST'])
def voice_speak():
"""Synthesize text to speech via smart TTS router.
Accepts JSON with:
- text (required)
- language (optional, auto-detected)
- voice (optional, saved-voice NAME for cloning; a path-like value is
dropped -- see _safe_voice_ref, #67)
- source (optional, context hint: chat_response/greeting/read_aloud/etc.)
- engine (optional, direct engine selection; TTSRouter accepts it only
when it is a known ENGINE_REGISTRY id, else ignores it)
The caller cannot choose where the WAV is written: the router writes to its
own TTS output dir, which /api/voice/audio serves. (Honouring a caller
output_path was an arbitrary file write on any node with a TTS engine, #67.)
"""
try:
from integrations.channels.media.tts_router import get_tts_router
data = request.get_json() or {}
text = data.get('text', '')
if not text:
return jsonify({'error': 'text is required'}), 400
router = get_tts_router()
result = router.synthesize(
text=text,
language=data.get('language'),
voice=_safe_voice_ref(data.get('voice')),
output_path=None,
source=data.get('source'),
engine_override=data.get('engine'),
)
resp = result.to_dict()
# Add audio_url for frontends to fetch the WAV
if result.path and not result.error:
resp['audio_url'] = f"/api/voice/audio/{os.path.basename(result.path)}"
code = 200 if not result.error else 500
return jsonify(resp), code
except ImportError as e:
return jsonify({'error': f'TTS not available: {e}'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/voice/voices', methods=['GET'])
def voice_list_voices():
"""List available TTS voices from all installed engines."""
try:
from integrations.channels.media.tts_router import get_tts_router
voices = get_tts_router().get_all_voices()
return jsonify({'voices': voices, 'count': len(voices)})
except ImportError as e:
return jsonify({'error': f'TTS not available: {e}'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/voice/clone', methods=['POST'])
def voice_clone():
"""Clone a voice from an audio sample (5+ seconds recommended).
Accepts engine param to route to specific cloning backend.
Default: luxtts (CPU) or pocket_tts.
"""
try:
data = request.get_json() or {}
# #67: audio_path is READ + copied into the voices dir and name is
# joined into it as .wav, so confine both (an unauth local caller
# is not covered by the network gate).
audio_path = _safe_sample_path(data.get('audio_path'))
name = _safe_clone_name(data.get('name'))
if not audio_path or not name:
return jsonify({
'error': 'audio_path (an audio file, no "..") and name '
'(no path separators) are required'}), 400
import json as _json
engine = data.get('engine', 'luxtts')
if engine == 'pocket_tts':
from integrations.service_tools.pocket_tts_tool import pocket_tts_clone_voice
result = pocket_tts_clone_voice(audio_path, name)
else:
from integrations.service_tools.luxtts_tool import luxtts_clone_voice
result = luxtts_clone_voice(audio_path, name)
parsed = _json.loads(result)
code = 200 if 'error' not in parsed else 500
return jsonify(parsed), code
except ImportError as e:
return jsonify({'error': f'TTS not available: {e}'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/voice/engines', methods=['GET'])
def voice_engines():
"""Report status of all TTS engines (installed, can_run, device, etc.)."""
try:
from integrations.channels.media.tts_router import get_tts_router
engines = get_tts_router().get_engine_status()
return jsonify({'engines': engines})
except ImportError as e:
return jsonify({'error': f'TTS not available: {e}'}), 503
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/voice/audio/', methods=['GET'])
def voice_audio(filename):
"""Serve a generated TTS audio file. Path-traversal safe."""
import os as _os
safe_name = _os.path.basename(filename)
if safe_name != filename or '..' in filename:
return jsonify({'error': 'invalid filename'}), 400
# Search in known TTS output directories
search_dirs = [
_os.path.expanduser('~/.hevolve/models/luxtts/output'),
_os.path.expanduser('~/.hevolve/models/pocket_tts/output'),
_os.path.expanduser('~/.hevolve/models/chatterbox/output'),
_os.path.expanduser('~/.hevolve/models/cosyvoice/output'),
_os.path.expanduser('~/.hevolve/models/indic_parler/output'),
_os.path.expanduser('~/.hevolve/models/f5_tts/output'),
_os.environ.get('TTS_TEMP_DIR', '/tmp/tts'),
]
# A game's composed sounds (media_agent keeps them there; hartos-3a F1).
try:
from integrations.service_tools.media_agent import composer_output_dir
search_dirs.append(str(composer_output_dir()))
except Exception as e:
logging.getLogger(__name__).warning(
'voice_audio: composer output dir unavailable: %s', e)
for d in search_dirs:
fpath = _os.path.join(d, safe_name)
if _os.path.isfile(fpath):
from flask import send_file
return send_file(fpath, mimetype='audio/wav')
return jsonify({'error': 'file not found'}), 404
# ---------------------------------------------------------------------------
# Video Generation API — orchestrates GPU tasks via hive mesh
# ---------------------------------------------------------------------------
@app.route('/video-gen/', methods=['POST'])
def video_gen():
"""Video generation endpoint — drop-in replacement for MakeItTalk.
Accepts the same request format as MakeItTalk's /video-gen/ endpoint.
Dispatches GPU subtasks (TTS, face crop, lip-sync) to local GPU or
hive mesh peers. Returns 202 with queue position + ETA.
Chatbot pipeline can point here instead of MakeItTalk.
"""
try:
data = request.get_json(force=True, silent=True) or {}
except Exception:
data = {}
if not data:
return jsonify({'error': 'No JSON body'}), 400
# Map user_id if present (chatbot sends 'uid' as request_id)
if 'uid' not in data:
data['uid'] = data.get('request_id', '')
try:
from integrations.agent_engine.video_orchestrator import get_video_orchestrator
orch = get_video_orchestrator()
result = orch.generate(data)
if 'error' in result and 'status' not in result:
return jsonify(result), 400
# Return 202 Accepted (async processing) — same as MakeItTalk
# Wrap in 'response' key for chatbot_pipeline compatibility
return jsonify({'response': result}), 202
except Exception as e:
logger.error("Video generation error: %s", e, exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/video-gen/status/', methods=['GET'])
def video_gen_status(job_id):
"""Check status of a video generation job."""
try:
from integrations.agent_engine.video_orchestrator import get_video_orchestrator
orch = get_video_orchestrator()
status = orch.get_job_status(job_id)
if status:
return jsonify(status)
return jsonify({'error': 'Job not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
# ---------------------------------------------------------------------------
# HART Skills API — ingest, list, and manage agent skills
# ---------------------------------------------------------------------------
@app.route('/api/skills/list', methods=['GET'])
def skills_list():
"""List all registered HART skills."""
try:
from integrations.skills import skill_registry
return jsonify({
'success': True,
'skills': skill_registry.list_skills(),
'count': skill_registry.count,
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/skills/ingest', methods=['POST'])
def skills_ingest():
"""Ingest a skill from Markdown content (with optional YAML frontmatter)."""
try:
from integrations.skills import skill_registry
data = request.get_json()
name = data.get('name', '')
content = data.get('content', '')
description = data.get('description', '')
tags = data.get('tags', [])
if not content:
return jsonify({'error': 'content is required'}), 400
skill_registry.ingest_markdown(name, content, description, tags)
skill_registry.save_config()
return jsonify({'success': True, 'message': f'Skill "{name}" ingested'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/skills/discover/local', methods=['POST'])
def skills_discover_local():
"""Discover skills from local filesystem paths."""
try:
from integrations.skills import skill_registry
data = request.get_json() or {}
paths = data.get('paths') # None = default paths
count = skill_registry.discover_local(paths)
skill_registry.save_config()
return jsonify({'success': True, 'discovered': count})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/skills/discover/github', methods=['POST'])
def skills_discover_github():
"""Discover skills from a GitHub repository."""
try:
from integrations.skills import skill_registry
data = request.get_json()
repo_url = data.get('repo_url', '')
branch = data.get('branch', 'main')
skills_path = data.get('skills_path', '.claude/skills')
if not repo_url:
return jsonify({'error': 'repo_url is required'}), 400
count = skill_registry.discover_github(repo_url, branch, skills_path)
skill_registry.save_config()
return jsonify({'success': True, 'discovered': count})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/skills/', methods=['GET'])
def skills_get(skill_name):
"""Get a specific skill's full details."""
try:
from integrations.skills import skill_registry
skill = skill_registry.get_skill(skill_name)
if not skill:
return jsonify({'error': f'Skill "{skill_name}" not found'}), 404
return jsonify({'success': True, 'skill': skill.to_dict()})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/skills/', methods=['DELETE'])
def skills_delete(skill_name):
"""Remove a skill from the registry."""
try:
from integrations.skills import skill_registry
if skill_registry.unregister_skill(skill_name):
skill_registry.save_config()
return jsonify({'success': True, 'message': f'Skill "{skill_name}" removed'})
return jsonify({'error': f'Skill "{skill_name}" not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Settings API — Compute Configuration ───────────────────────────
@app.route('/api/settings/compute', methods=['GET'])
@_json_endpoint
def settings_compute_get():
"""Return merged compute policy (env > DB > defaults).
Access: any authenticated node operator.
"""
import os as _os
node_id = _os.environ.get('HEVOLVE_NODE_ID', 'local')
from integrations.agent_engine.compute_config import get_compute_policy
policy = get_compute_policy(node_id)
# Add provider identity from PeerNode (single source of truth)
provider_info = {}
try:
from integrations.social.models import db_session, PeerNode
with db_session() as db:
peer = db.query(PeerNode).filter_by(node_id=node_id).first()
if peer:
provider_info = {
'electricity_rate_kwh': peer.electricity_rate_kwh,
'cause_alignment': peer.cause_alignment,
}
except Exception:
logging.getLogger(__name__).exception("settings_compute_get: swallowed Exception")
return jsonify({**policy, **provider_info, 'node_id': node_id})
@app.route('/api/settings/compute', methods=['PUT'])
@_json_endpoint
def settings_compute_put():
"""Update compute configuration. Single endpoint, writes to correct table per field.
Policy fields → NodeComputeConfig (local-only).
Provider identity → PeerNode (gossipped to network).
Access: node operator or admin. Tier-aware: central nodes cannot set allow_metered_for_hive.
"""
import os as _os
data = request.get_json() or {}
node_id = _os.environ.get('HEVOLVE_NODE_ID', 'local')
node_tier = _os.environ.get('HEVOLVE_NODE_TIER', 'flat')
# Tier guard: central nodes cannot opt into metered for hive
if node_tier == 'central' and data.get('allow_metered_for_hive'):
return jsonify({'error': 'Central nodes cannot enable metered APIs for hive'}), 403
# Policy fields → NodeComputeConfig
policy_fields = {
'compute_policy', 'hive_compute_policy', 'max_hive_gpu_pct',
'allow_metered_for_hive', 'metered_daily_limit_usd',
'offered_gpu_hours_per_day', 'accept_thought_experiments',
'accept_frontier_training', 'auto_settle', 'min_settlement_spark',
}
# Provider identity → PeerNode
peer_fields = {'electricity_rate_kwh', 'cause_alignment'}
policy_updates = {k: v for k, v in data.items() if k in policy_fields}
peer_updates = {k: v for k, v in data.items() if k in peer_fields}
from integrations.social.models import db_session, NodeComputeConfig, PeerNode
with db_session() as db:
if policy_updates:
config = db.query(NodeComputeConfig).filter_by(node_id=node_id).first()
if not config:
config = NodeComputeConfig(node_id=node_id)
db.add(config)
for key, val in policy_updates.items():
setattr(config, key, val)
if peer_updates:
peer = db.query(PeerNode).filter_by(node_id=node_id).first()
if peer:
for key, val in peer_updates.items():
setattr(peer, key, val)
db.commit()
# Invalidate cache
from integrations.agent_engine.compute_config import invalidate_cache
invalidate_cache(node_id)
return jsonify({'updated': True, 'node_id': node_id,
'policy_fields': list(policy_updates.keys()),
'peer_fields': list(peer_updates.keys())})
@app.route('/api/settings/compute/provider', methods=['GET'])
@_json_endpoint
def settings_compute_provider():
"""Provider dashboard: contribution score, GPU hours, inferences, energy,
total Spark earned, pending settlements, cause alignment.
Access: node operator. Deployment-mode aware (flat/regional/central).
"""
import os as _os
node_id = _os.environ.get('HEVOLVE_NODE_ID', 'local')
node_tier = _os.environ.get('HEVOLVE_NODE_TIER', 'flat')
from integrations.social.models import db_session, PeerNode, MeteredAPIUsage
from integrations.social.hosting_reward_service import HostingRewardService
from sqlalchemy import func as sa_func
with db_session() as db:
peer = db.query(PeerNode).filter_by(node_id=node_id).first()
if not peer:
return jsonify({'error': 'Node not registered', 'node_id': node_id}), 404
# Contribution score
score_result = HostingRewardService.compute_contribution_score(db, node_id)
# Pending settlements
pending_count = db.query(sa_func.count(MeteredAPIUsage.id)).filter(
MeteredAPIUsage.node_id == node_id,
MeteredAPIUsage.settlement_status == 'pending',
).scalar() or 0
pending_usd = db.query(
sa_func.coalesce(sa_func.sum(MeteredAPIUsage.actual_usd_cost), 0)
).filter(
MeteredAPIUsage.node_id == node_id,
MeteredAPIUsage.settlement_status == 'pending',
).scalar() or 0.0
# Reward summary
reward_summary = HostingRewardService.get_reward_summary(db, node_id)
return jsonify({
'node_id': node_id,
'node_tier': node_tier,
'contribution': score_result,
'compute_stats': {
'gpu_hours_served': peer.gpu_hours_served or 0,
'total_inferences': peer.total_inferences or 0,
'energy_kwh_contributed': peer.energy_kwh_contributed or 0,
'metered_api_costs_absorbed': peer.metered_api_costs_absorbed or 0,
},
'provider_identity': {
'cause_alignment': peer.cause_alignment,
'electricity_rate_kwh': peer.electricity_rate_kwh,
},
'pending_settlements': {
'count': pending_count,
'total_usd': round(float(pending_usd), 4),
},
'reward_summary': reward_summary,
})
@app.route('/api/settings/compute/provider/join', methods=['POST'])
@_json_endpoint
def settings_compute_provider_join():
"""Simple provider onboarding. Creates NodeComputeConfig + sets PeerNode identity.
Body: {cause_alignment?, electricity_rate_kwh?, offered_gpu_hours_per_day?, compute_policy?}
Access: any node. Creates config with sensible defaults.
"""
import os as _os
data = request.get_json() or {}
node_id = _os.environ.get('HEVOLVE_NODE_ID', 'local')
from integrations.social.models import db_session, NodeComputeConfig, PeerNode
with db_session() as db:
# Create or update NodeComputeConfig
config = db.query(NodeComputeConfig).filter_by(node_id=node_id).first()
if not config:
config = NodeComputeConfig(node_id=node_id)
db.add(config)
for field in ('compute_policy', 'offered_gpu_hours_per_day',
'accept_thought_experiments', 'accept_frontier_training'):
if field in data:
setattr(config, field, data[field])
# Set provider identity on PeerNode
peer = db.query(PeerNode).filter_by(node_id=node_id).first()
if peer:
if 'cause_alignment' in data:
peer.cause_alignment = data['cause_alignment']
elif not peer.cause_alignment:
peer.cause_alignment = 'democratize_compute'
if 'electricity_rate_kwh' in data:
peer.electricity_rate_kwh = data['electricity_rate_kwh']
db.commit()
# Invalidate cache
from integrations.agent_engine.compute_config import invalidate_cache
invalidate_cache(node_id)
# Return the merged config
from integrations.agent_engine.compute_config import get_compute_policy
policy = get_compute_policy(node_id)
return jsonify({
'joined': True,
'node_id': node_id,
'config': policy,
'message': 'Welcome to the HART OS compute network. '
'Your contribution helps democratize access to AI.',
})
# ─── Remote Desktop API ──────────────────────────────────────────
# RustDesk + Sunshine/Moonlight bridge, session management,
# engine selection, device identity.
@app.route('/api/remote-desktop/status', methods=['GET'])
@_json_endpoint
def remote_desktop_api_status():
"""Device ID, engine status, active sessions."""
from integrations.remote_desktop.engine_selector import get_all_status
from integrations.remote_desktop.device_id import get_device_id, format_device_id
from integrations.remote_desktop.session_manager import get_session_manager
device_id = get_device_id()
sm = get_session_manager()
sessions = sm.get_active_sessions()
status = get_all_status()
status['device_id'] = device_id
status['formatted_id'] = format_device_id(device_id)
status['active_sessions'] = [
{'session_id': s.session_id, 'host_device_id': s.host_device_id,
'mode': s.mode.value, 'state': s.state.value,
'viewers': s.viewer_device_ids}
for s in sessions
]
return jsonify(status)
@app.route('/api/remote-desktop/host', methods=['POST'])
@_json_endpoint
def remote_desktop_api_host():
"""Start hosting. Body: {mode?, engine?}. Returns device_id + password."""
data = request.get_json() or {}
from integrations.remote_desktop.session_manager import (
get_session_manager, SessionMode,
)
from integrations.remote_desktop.device_id import get_device_id, format_device_id
device_id = get_device_id()
sm = get_session_manager()
mode_str = data.get('mode', 'full_control')
mode = SessionMode(mode_str) if mode_str in [m.value for m in SessionMode] else SessionMode.FULL_CONTROL
password = sm.generate_otp(device_id)
engine_pref = data.get('engine', 'auto')
result = {
'device_id': device_id,
'formatted_id': format_device_id(device_id),
'password': password,
'mode': mode.value,
'engine': engine_pref,
}
# Start RustDesk
if engine_pref in ('auto', 'rustdesk'):
try:
from integrations.remote_desktop.rustdesk_bridge import get_rustdesk_bridge
bridge = get_rustdesk_bridge()
if bridge.available:
bridge.set_password(password)
bridge.start_service()
rd_id = bridge.get_id()
if rd_id:
result['rustdesk_id'] = rd_id
result['engine'] = 'rustdesk'
except Exception:
logging.getLogger(__name__).exception("remote_desktop_api_host: swallowed Exception")
# Start Sunshine
if engine_pref in ('auto', 'sunshine'):
try:
from integrations.remote_desktop.sunshine_bridge import get_sunshine_bridge
bridge = get_sunshine_bridge()
if bridge.available:
bridge.start_service()
result['sunshine_running'] = bridge.is_running()
if engine_pref == 'sunshine':
result['engine'] = 'sunshine'
except Exception:
logging.getLogger(__name__).exception("remote_desktop_api_host: swallowed Exception")
return jsonify(result)
@app.route('/api/remote-desktop/connect', methods=['POST'])
@_json_endpoint
def remote_desktop_api_connect():
"""Connect to remote device. Body: {device_id, password, mode?, engine?}."""
data = request.get_json() or {}
device_id = data.get('device_id')
password = data.get('password')
if not device_id or not password:
return jsonify({'error': 'device_id and password required'}), 400
engine = data.get('engine', 'auto')
file_transfer = data.get('mode') == 'file_transfer'
# Try RustDesk
if engine in ('auto', 'rustdesk'):
try:
from integrations.remote_desktop.rustdesk_bridge import get_rustdesk_bridge
bridge = get_rustdesk_bridge()
if bridge.available:
ok, msg = bridge.connect(device_id, password=password,
file_transfer=file_transfer)
if ok:
return jsonify({'success': True, 'engine': 'rustdesk',
'device_id': device_id, 'message': msg})
except Exception:
logging.getLogger(__name__).exception("remote_desktop_api_connect: swallowed Exception")
# Try Moonlight
if engine in ('auto', 'moonlight'):
try:
from integrations.remote_desktop.sunshine_bridge import get_moonlight_bridge
bridge = get_moonlight_bridge()
if bridge.available:
ok, msg = bridge.stream(device_id)
if ok:
return jsonify({'success': True, 'engine': 'moonlight',
'device_id': device_id, 'message': msg})
except Exception:
logging.getLogger(__name__).exception("remote_desktop_api_connect: swallowed Exception")
return jsonify({'success': False, 'error': 'No engine available'}), 503
@app.route('/api/remote-desktop/sessions', methods=['GET'])
@_json_endpoint
def remote_desktop_api_sessions():
"""List active remote desktop sessions."""
from integrations.remote_desktop.session_manager import get_session_manager
sm = get_session_manager()
sessions = sm.get_active_sessions()
return jsonify({
'sessions': [
{'session_id': s.session_id, 'host_device_id': s.host_device_id,
'mode': s.mode.value, 'state': s.state.value,
'viewers': s.viewer_device_ids}
for s in sessions
]
})
@app.route('/api/remote-desktop/disconnect/', methods=['POST'])
@_json_endpoint
def remote_desktop_api_disconnect(session_id):
"""End a specific session."""
from integrations.remote_desktop.session_manager import get_session_manager
sm = get_session_manager()
sm.disconnect_session(session_id)
return jsonify({'disconnected': session_id})
@app.route('/api/remote-desktop/engines', methods=['GET'])
@_json_endpoint
def remote_desktop_api_engines():
"""List available engines with install commands."""
from integrations.remote_desktop.engine_selector import (
get_available_engines, get_all_status, Engine,
)
status = get_all_status()
available = get_available_engines()
return jsonify({
'available': [e.value for e in available],
'engines': status['engines'],
'install_recommendations': status.get('install_recommendations', []),
})
@app.route('/api/remote-desktop/select-engine', methods=['POST'])
@_json_endpoint
def remote_desktop_api_select_engine():
"""Auto-select best engine. Body: {use_case?, role?, prefer?}."""
data = request.get_json() or {}
from integrations.remote_desktop.engine_selector import (
select_engine, UseCase, Engine,
)
use_case_str = data.get('use_case', 'general')
role = data.get('role', 'viewer')
prefer_str = data.get('prefer')
uc = UseCase(use_case_str) if use_case_str in [u.value for u in UseCase] else UseCase.GENERAL
prefer = Engine(prefer_str) if prefer_str and prefer_str in [e.value for e in Engine] else None
engine = select_engine(uc, role=role, prefer=prefer)
return jsonify({'engine': engine.value, 'use_case': uc.value, 'role': role})
def _init_skills():
"""Initialize skill registry — load persisted skills + discover local."""
try:
from integrations.skills import skill_registry
skill_registry.load_config()
skill_registry.discover_local()
if skill_registry.count > 0:
app.logger.info(f"HART skills ready: {skill_registry.count} skills loaded")
except Exception as e:
logger.debug(f"Skill init skipped: {e}")
def _init_runtime_tools():
"""Initialize runtime tools in a background thread.
Restores previously-running tools from state file.
Called from main() on startup.
"""
try:
from integrations.service_tools.runtime_manager import runtime_tool_manager
runtime_tool_manager.load_state()
except Exception as e:
logger.warning(f"Runtime tool init failed: {e}")
def _validate_startup():
"""Validate critical configuration at startup with helpful messages."""
import sys
warnings = []
# Python version check
v = sys.version_info
if v.major != 3 or v.minor < 10 or v.minor > 11:
warnings.append(
f"Python {v.major}.{v.minor}.{v.micro} detected. "
f"HART OS requires Python 3.10 or 3.11 (pydantic 1.10.9 compat).")
# Config file check
config_path = os.path.join(os.path.dirname(__file__), 'config.json')
langchain_config_path = os.path.join(os.path.dirname(__file__), 'langchain_config.json')
if not os.path.exists(config_path) and not os.path.exists(langchain_config_path):
warnings.append(
"No config.json or langchain_config.json found. "
"Create config.json with API keys (OPENAI, GROQ, etc.) for full functionality.")
# .env check
env_path = os.path.join(os.path.dirname(__file__), '.env')
if not os.path.exists(env_path):
warnings.append(
"No .env file found. Create .env with OPENAI_API_KEY, GROQ_API_KEY, "
"LANGCHAIN_API_KEY for LLM access.")
# Database directory check — prefer DB path or user Documents for bundled mode
_db_p = os.environ.get('HEVOLVE_DB_PATH', '')
if _db_p and _db_p != ':memory:' and os.path.isabs(_db_p):
db_dir = os.path.join(os.path.dirname(_db_p), 'agent_data')
elif os.environ.get('NUNBA_BUNDLED') or getattr(sys, 'frozen', False):
from core.platform_paths import get_agent_data_dir as _get_ad_dir
db_dir = _get_ad_dir()
else:
db_dir = os.path.join(os.path.dirname(__file__), 'agent_data')
if not os.path.isdir(db_dir):
try:
os.makedirs(db_dir, exist_ok=True)
except OSError as e:
warnings.append(f"Cannot create agent_data directory: {e}")
# ── Central instance hardening ──
_central_logger = logging.getLogger('hevolve_social')
node_tier = os.environ.get('HEVOLVE_NODE_TIER', 'flat')
if node_tier == 'central':
# Fix 3: TLS check
if not os.environ.get('TLS_CERT_PATH'):
_central_logger.warning("CENTRAL HARDENING: No TLS_CERT_PATH set — production should use HTTPS")
# Fix 5: Secret presence validation
api_key = os.environ.get('OPENAI_API_KEY', '')
if not api_key or api_key.startswith('sk-xxx') or api_key == 'your-key':
_central_logger.critical("CENTRAL HARDENING: OPENAI_API_KEY missing or placeholder")
# Fix 6: DB encryption check
db_url = os.environ.get('HEVOLVE_DB_URL') or os.environ.get('DATABASE_URL', '')
if not db_url or 'sqlite' in db_url.lower():
_central_logger.warning("CENTRAL HARDENING: SQLite detected — production should use PostgreSQL or sqlcipher")
# Fix 7: Block dev mode
if os.environ.get('HEVOLVE_DEV_MODE', '').lower() == 'true':
os.environ['HEVOLVE_DEV_MODE'] = 'false'
_central_logger.critical("CENTRAL HARDENING: Dev mode FORCED OFF on central instance")
if warnings:
logger = logging.getLogger('hevolve_social')
logger.warning("=" * 60)
logger.warning("STARTUP VALIDATION WARNINGS")
for w in warnings:
logger.warning(f" - {w}")
logger.warning("=" * 60)
return warnings
def main():
"""
Main entry point for hevolve-server CLI command.
Starts the Flask server using waitress.
"""
# Boot integrity verification (deferred from import time)
hevolve_verify_boot()
# NOTE: prompts_backup.snapshot_at_boot runs at module-import
# time (line ~452) so it fires for BOTH the CLI path here AND
# Nunba's library-import path. Removing the duplicate call here
# avoids two snapshots per CLI boot.
# Guardrail hash verification — refuse to boot with tampered
# hive_guardrails values unless HEVOLVE_GUARDRAIL_HASH_ENFORCE=0
# (dev override). The module-level call inside hive_guardrails is
# self-consistent at pristine import; this explicit boot re-check
# surfaces any post-import monkey-patch or dynamic module substitution
# before any ConstitutionalFilter entry point has a chance to run.
try:
from security.hive_guardrails import (
enforce_guardrail_integrity,
get_guardrail_hash,
)
enforce_guardrail_integrity()
logging.getLogger('hevolve_social').info(
"[Guardrail] hash verified: %s", get_guardrail_hash()[:16] + '...')
except RuntimeError:
# enforce_guardrail_integrity already logged CRITICAL and we want
# the hard abort to propagate — this is a guardian-angel integrity
# check and we refuse to boot.
raise
except Exception as e:
logging.getLogger('hevolve_social').warning(
f"[Guardrail] hash verification skipped (import error): {e}")
_validate_startup()
# Bootstrap EventBus (required before local subscribers)
try:
from core.platform.bootstrap import bootstrap_platform
bootstrap_platform()
except Exception as e:
logging.getLogger(__name__).warning(f"Platform bootstrap failed: {e}")
# Bootstrap local Crossbar subscribers (replaces cloud chatbot_pipeline)
try:
from core.peer_link.local_subscribers import bootstrap_local_subscribers
bootstrap_local_subscribers()
except Exception as e:
logging.getLogger(__name__).warning(f"Local subscribers bootstrap failed: {e}")
# Start runtime tools restoration in background
import threading
tools_thread = threading.Thread(target=_init_runtime_tools, daemon=True)
tools_thread.start()
# Initialize HART skill registry (load persisted + discover local)
skills_thread = threading.Thread(target=_init_skills, daemon=True)
skills_thread.start()
# Agent engine: daemon supervisor, Phase-2 goal bootstrap, dispatch.
#
# `init_social` used to do this and deliberately stopped — see
# integrations/social/__init__.py ("agent engine init delegated to
# caller ... ONE caller, ONE call site"). Nunba's main.py picked the
# responsibility up; this standalone launcher never did. So every
# Docker / OS deployment booted with no daemon supervisor and no
# Phase-2 bootstrap at all: seeded goals sat `active` forever, nothing
# was ever dispatched, and no bootstrap line appeared in the logs to
# say so. (Verified on the central node: 72 goals, all `active`, zero
# dispatched, no engine log lines.)
#
# Called here at the tail of main() on purpose:
# - the module is fully imported by now, so the deferred thread's
# heavy imports cannot race top-level ones — that race is the
# documented deadlock in integrations/agent_engine/__init__.py;
# - Flask has not served a request yet, so Phase-1's
# register_blueprint() calls are still legal.
# init_agent_engine is idempotent, so a launcher that already called it
# (Nunba) is unaffected.
try:
from integrations.agent_engine import init_agent_engine
init_agent_engine(app)
except Exception as e:
logging.getLogger(__name__).error(
"Agent engine init failed — no seeded goal will ever execute on "
"this node: %s", e, exc_info=True)
# Channel adapters: same class of gap as the agent engine above.
# hartos_bootstrap.py calls FlaskChannelIntegration.start() as part of the
# bundled boot sequence, but this standalone launcher never did — so
# `_loop` stayed None and every persisted UserChannelBinding sat dead
# until an unrelated WhatsApp code path happened to trigger start() on
# demand (see _ensure_whatsapp_live_adapter). In practice that meant
# Discord/Telegram/Slack had to be re-bound by hand after every restart,
# even though the bindings table exists precisely to survive one.
#
# start() rehydrates adapters from those bindings and is idempotent, so a
# launcher that already started channels (Nunba) is unaffected.
try:
from integrations.channels.flask_integration import (
get_channel_integration, register_status_routes,
)
_channels = get_channel_integration()
# Inbound webhook seam, same omission as above: hartos_bootstrap and
# run_debug both call this, standalone never did — so the
# /channels/webhook/ route did not exist here at all and
# EVERY webhook-based channel (google_chat, line, messenger,
# instagram, twitter, viber, wechat, zalo) was unreachable inbound,
# returning 404 to the provider. Must run before _serve_app.
try:
_channels.register_webhook_routes(app)
except Exception as e:
logging.getLogger(__name__).warning(
"Inbound channel webhook routes not registered — webhook-based "
"channels cannot receive messages on this node: %s", e,
exc_info=True)
# Same class of gap again: hartos_bootstrap/run_debug reach these two
# routes via init_channels(app), which standalone never called — so
# GET /channels/status and POST /channels/send 404'd here even
# though _channels itself is live. register_status_routes wires them
# onto the SAME get_channel_integration() singleton rather than
# going through init_channels(), which would construct a second,
# separate FlaskChannelIntegration and orphan whatever this one
# already has running (e.g. a WhatsApp adapter started early by
# _ensure_whatsapp_live_adapter).
try:
register_status_routes(app, _channels)
except Exception as e:
logging.getLogger(__name__).warning(
"/channels/status and /channels/send not registered: %s", e,
exc_info=True)
_channels.start()
except Exception as e:
logging.getLogger(__name__).warning(
"Channel adapters not started — persisted channel bindings will "
"stay disconnected on this node: %s", e, exc_info=True)
from core.port_registry import get_port
_serve_app(app, host='0.0.0.0', port=get_port('backend'))
def _serve_app(app, host: str, port: int) -> None:
"""Boot the WSGI app on Hypercorn (preferred) or Waitress (fallback).
Hypercorn = ASGI: asyncio event loop multiplexes connection IO so
idle keep-alive / SSE clients don't burn worker threads (waitress
holds one thread per connection regardless of activity). Sync
Flask handlers run in `loop.run_in_executor()` against a pool of
HEVOLVE_WORKER_THREADS (default 256) — slow paths (LLM inference,
pip install in /tts/setup-engine) occupy executor threads but
never block the IO layer, so /dashboard/agents and /health stay
responsive while a TTS install grinds.
Single-process today. Multi-process (HEVOLVE_WORKERS=N) requires
sharding the in-process singletons first (EventBus, ResourceGovernor,
agent_daemon, watchdog, goal_seeding) — each fork would otherwise
spawn N parallel agent fleets fighting over the same goals + the
same Windows Job Object handle. Until then `workers=1` is the
correct value; same code path will lift to N workers for free
once the sharding work lands.
Falls back to Waitress on ImportError so older deployments / partial
installs / cx_Freeze bundles missing the hypercorn h2/wsproto chain
still boot — this preserves the prior behavior bit-for-bit.
"""
log = logging.getLogger('hevolve_social')
try:
worker_threads = int(os.environ.get('HEVOLVE_WORKER_THREADS', '256'))
except (TypeError, ValueError):
worker_threads = 256
try:
import asyncio
from concurrent.futures import ThreadPoolExecutor
from core.serve import build_asgi_app, make_hypercorn_config
from hypercorn.asyncio import serve as _hcserve
# core.serve owns what all three entry points share: these four Config
# settings and the peer_link-capable ASGI wrapping. This one, Nunba
# app.py and Nunba main.py each carried their own copy, which is how
# the peer_link mount ended up on one of the three while the other two
# answered 403 on /peer_link. What this function legitimately varies
# stays here: bind, HEVOLVE_WORKER_THREADS=256, thread prefix 'hartos',
# and the ImportError fallback to Waitress below. Config and
# AsyncioWSGIMiddleware are imported inside core.serve, so a missing
# hypercorn still raises ImportError in this try block.
config = make_hypercorn_config([f'{host}:{port}'])
asgi_app = build_asgi_app(app)
async def _runner():
loop = asyncio.get_running_loop()
loop.set_default_executor(
ThreadPoolExecutor(max_workers=worker_threads,
thread_name_prefix='hartos'))
await _hcserve(asgi_app, config)
log.info(
f"Starting Hypercorn (ASGI) on {host}:{port} "
f"(executor_threads={worker_threads}, single-process)"
)
asyncio.run(_runner())
return
except ImportError as exc:
log.warning(
f"Hypercorn unavailable ({exc}) — falling back to Waitress")
# The variant thread budget applies HERE too. hart-backend.nix exports
# HEVOLVE_WORKER_THREADS as each variant's budget and says that export is what
# keeps ExecStart safe against TasksMax; hart-app ships no hypercorn, so every
# OS node takes this path, and a hard-coded 50 (plus the import threads) is
# more than edge's TasksMax=64. Measured in CI 2026-09-24 (nixosTests
# hart-peer-discovery): the edge backend died five times with "can't start
# new thread" and "fork rejected by pids controller", then hit its start
# limit. Unset (dev box, the Nunba bundle) keeps the old 50, unchanged.
raw_budget = os.environ.get('HEVOLVE_WORKER_THREADS')
try:
waitress_threads = int(raw_budget) if raw_budget else 50
except ValueError:
waitress_threads = 50
if waitress_threads < 1:
waitress_threads = 1
log.info(f"Starting Waitress (WSGI) on {host}:{port} (threads={waitress_threads})")
serve(app, host=host, port=port, threads=waitress_threads)
if __name__ == '__main__':
main()
# app.debug = True
# flask_thread = threading.Thread(target=lambda: serve(app, host='0.0.0.0', port=6777))
# flask_thread.daemon = True
# flask_thread.start()
# from crossbar_server import component
# # Run the WAMP client
# run([component])
# Read from the environment as this node's own configuration or key
# material: a vault or consent-card value must never set these.
# tests/unit/test_env_secrets_declared.py fails on a secret read not
# declared here or in ENV_SECRETS.
ENV_NOT_FROM_VAULT = (
'FLASK_SECRET_KEY',
'HEVOLVE_LLM_API_KEY',
'HEVOLVE_REQUIRE_AUTH',
'SECRET_KEY',
)