"""``AIAgent.run_conversation`` / ``chat`` façade. Turn admission around ``conversation_loop.run_conversation``: durable cross-process session turn lease + refresher thread and liveness watchdog (``agent.turn_facade_lease``), relay/accounting/portal scopes, and balanced start/finish marks. Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged. """ import logging import uuid from typing import Any, Dict, List, Optional from agent.lazy_forward import forward as _forward from tools.interrupt import set_interrupt as _set_interrupt # noqa: F401 (tests patch it here) # Same logger name as the origin module so log records / caplog filters are unchanged. logger = logging.getLogger("run_agent") class TurnFacadeMixin: """run_conversation()/chat() (see module docstring).""" def run_conversation( self, user_message: Any, system_message: str = None, conversation_history: List[Dict[str, Any]] = None, task_id: str = None, stream_callback: Optional[callable] = None, persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, persist_user_display_kind: Optional[str] = None, persist_user_display_metadata: Optional[Dict[str, Any]] = None, persist_user_platform_id: Optional[str] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: """Forwarder — see ``agent.conversation_loop.run_conversation``.""" # A review shares this session_id for cache parity: fence review startup or interrupt an admitted # request and await its exit before opening live-turn instrumentation. from agent.background_review import cancel_background_review_for_live_turn cancel_background_review_for_live_turn(self) from agent import relay_runtime from agent.aux_accounting import reset_accounting_context, set_accounting_context from agent.auxiliary_client import scoped_runtime_main from agent.conversation_loop import run_conversation from agent.portal_tags import ( reset_affinity_scope, reset_conversation_context, set_affinity_scope, set_conversation_context, ) from agent.prompt_cache_scope import declared_conversation_scope_safe from agent.review_idle_queue import QUEUE as _review_queue from agent.subagent_lifecycle import bind_subagent_parent from agent.turn_facade_lease import admit_durable_turn_lease from hermes_cli.observability.relay_shared_metrics import finish_task_run, start_task_run effective_task_id = task_id or str(uuid.uuid4()) session_id = str(getattr(self, "session_id", None) or "") task_context = { "session_id": session_id, "task_id": effective_task_id, "platform": getattr(self, "platform", None) or "", } relay_turn_id = f"{session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}" self._relay_pending_turn_id = relay_turn_id relay_parent_session_id = ( str(getattr(self, "_parent_session_id", None) or "") if task_context["platform"] == "subagent" else "" ) relay_lease = relay_turn = lease = None # Scope tokens start None: early returns leave the try before the set_*() calls and the finally # resets each one unconditionally. token = affinity_token = acct_token = None task_started = task_finished = False relay_outcome = "failed" try: # Turn liveness for the deferred-review idle queue: first statement of the try so the finally's # note_turn_finished balances every exit. _review_queue.note_turn_started() admission = admit_durable_turn_lease( self, session_id=session_id, relay_turn_id=relay_turn_id, task_context=task_context, conversation_history=conversation_history, ) if admission.early_result is not None: relay_outcome = ( "cancelled" if admission.early_result.get("interrupted") else "timed_out" ) return admission.early_result lease = admission.lease conversation_history = admission.conversation_history relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( profile_key=relay_runtime.current_profile_key(), session_id=task_context["session_id"], platform=task_context["platform"], parent_session_id=relay_parent_session_id, model=str(getattr(self, "model", None) or ""), ) relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn( relay_lease, turn_id=relay_turn_id, task_id=effective_task_id ) # Minimal relay-runtime shims (tests, external) may lack the opt-out flag: default enabled. if getattr(relay_turn, "relay_enabled", True): start_task_run( **task_context, parent_session_id=getattr(self, "_parent_session_id", None) or "", ) task_started = True # Publish the conversation id for ambient Nous Portal tagging: every LLM call in this turn # (loop, compression, vision, MoA, review forks) inherits `conversation=`. token = set_conversation_context(self._conversation_root_id()) # Routing/affinity scope the HOST declared; providers fall back to the id above when unset. affinity_token = set_affinity_scope(declared_conversation_scope_safe(self)) # Session accounting handles so auxiliary calls record usage into session_model_usage. acct_token = set_accounting_context( getattr(self, "_session_db", None), getattr(self, "session_id", None) ) # Keep the ContextVar scope local (tokens on the agent may be observed from another thread). with bind_subagent_parent(self), scoped_runtime_main({}): try: if lease is not None: lease.start() result = run_conversation( self, user_message, system_message, conversation_history, effective_task_id, stream_callback, persist_user_message, persist_user_timestamp=persist_user_timestamp, persist_user_display_kind=persist_user_display_kind, persist_user_display_metadata=persist_user_display_metadata, persist_user_platform_id=persist_user_platform_id, moa_config=moa_config, ) finally: # Post-loop relay/task finalization must not receive a late refresh interrupt. The # interrupt clear itself waits for the thread join in the outer finally. if lease is not None: lease.stop_refresher() terminal = result if isinstance(result, dict) else {} if terminal.get("interrupted") is True: relay_outcome = "cancelled" elif terminal.get("failed") is True: relay_outcome = "failed" else: relay_outcome = "success" relay_runtime.SESSION_COORDINATOR.finish_logical_calls(relay_turn, outcome=relay_outcome) if task_started: task_finished = True finish_task_run(**task_context, result=result) return result except BaseException as exc: if isinstance(exc, (KeyboardInterrupt, InterruptedError)) or ( type(exc).__name__ == "CancelledError" ): relay_outcome = "cancelled" elif isinstance(exc, TimeoutError): relay_outcome = "timed_out" if relay_turn is not None: relay_runtime.SESSION_COORDINATOR.finish_logical_calls( relay_turn, outcome=relay_outcome ) if task_started and not task_finished: task_finished = True finish_task_run(**task_context, error=exc) raise finally: try: if relay_turn is not None: relay_runtime.SESSION_COORDINATOR.end_turn(relay_turn, outcome=relay_outcome) finally: try: if relay_lease is not None: relay_runtime.SESSION_COORDINATOR.release_conversation(relay_lease) finally: if lease is not None: lease.stop_refresher() lease.join_threads() # Clear any refresher interrupt fired between stop and join; must run AFTER join. lease.clear_interrupt() lease.release() # Always clear mid-turn labels when the turn exits — including interrupted early # returns that skip finalize_turn. Keep ts. try: self._reset_activity_labels_after_turn() except Exception: pass if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id: self._relay_pending_turn_id = None if acct_token is not None: reset_accounting_context(acct_token) if token is not None: reset_conversation_context(token) if affinity_token is not None: reset_affinity_scope(affinity_token) # Balance note_turn_started on every exit so the idle queue's live-turn count # cannot leak. try: _review_queue.note_turn_finished() except Exception: pass def chat(self, message: str, stream_callback: Optional[callable] = None) -> str: """Final response string of one turn; ``stream_callback`` receives each text delta.""" return self.run_conversation(message, stream_callback=stream_callback)["final_response"] _run_codex_app_server_turn = _forward("agent.codex_runtime", "run_codex_app_server_turn")